fix(header): fix missing Divider, Badge, IconUserShield and navigate
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Grid, Image, Stack, useMantineColorScheme } from "@mantine/core";
|
||||
import { Grid, Image, Stack } from "@mantine/core";
|
||||
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { ActivityList } from "./dashboard/activity-list";
|
||||
import { ChartAPBDes } from "./dashboard/chart-apbdes";
|
||||
import { ChartSurat } from "./dashboard/chart-surat";
|
||||
@@ -32,8 +34,38 @@ const sdgsData = [
|
||||
];
|
||||
|
||||
export function DashboardContent() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const [stats, setStats] = useState({
|
||||
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
||||
residents: { total: 0, heads: 0, poor: 0 },
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const [complaintRes, residentRes] = await Promise.all([
|
||||
apiClient.GET("/api/complaint/stats"),
|
||||
apiClient.GET("/api/resident/stats"),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
complaints: (complaintRes.data as any)?.data || {
|
||||
total: 0,
|
||||
baru: 0,
|
||||
proses: 0,
|
||||
selesai: 0,
|
||||
},
|
||||
residents: (residentRes.data as any)?.data || { total: 0, heads: 0, poor: 0 },
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats", error);
|
||||
setStats((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -42,36 +74,36 @@ export function DashboardContent() {
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<StatCard
|
||||
title="Surat Minggu Ini"
|
||||
value={99}
|
||||
detail="14 baru, 14 diproses"
|
||||
trend="12% dari minggu lalu ↗ +12%"
|
||||
trendValue={12}
|
||||
value={0}
|
||||
detail="Menunggu integrasi riil"
|
||||
trend="0%"
|
||||
trendValue={0}
|
||||
icon={<FileText style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<StatCard
|
||||
title="Pengaduan Aktif"
|
||||
value={28}
|
||||
detail="14 baru, 14 diproses"
|
||||
value={stats.complaints.baru + stats.complaints.proses}
|
||||
detail={`${stats.complaints.baru} baru, ${stats.complaints.proses} diproses`}
|
||||
icon={<MessageCircle style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<StatCard
|
||||
title="Layanan Selesai"
|
||||
value={156}
|
||||
detail="bulan ini"
|
||||
trend="+8%"
|
||||
trendValue={8}
|
||||
value={stats.complaints.selesai}
|
||||
detail="Total diselesaikan"
|
||||
trend="+0%"
|
||||
trendValue={0}
|
||||
icon={<CheckCircle style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||
<StatCard
|
||||
title="Kepuasan Warga"
|
||||
value="87.2%"
|
||||
detail="dari 482 responden"
|
||||
title="Total Penduduk"
|
||||
value={stats.residents.total.toLocaleString()}
|
||||
detail={`${stats.residents.heads} Kepala Keluarga`}
|
||||
icon={<Users style={{ width: "70%", height: "70%" }} />}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -102,8 +134,8 @@ export function DashboardContent() {
|
||||
|
||||
{/* Section 6: SDGs Desa Cards */}
|
||||
<Grid gutter="md">
|
||||
{sdgsData.map((sdg, index) => (
|
||||
<Grid.Col key={index} span={{ base: 9, md: 3 }}>
|
||||
{sdgsData.map((sdg) => (
|
||||
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
||||
<SDGSCard
|
||||
image={<Image src={sdg.image} alt={sdg.title} />}
|
||||
title={sdg.title}
|
||||
|
||||
@@ -48,9 +48,9 @@ export function ActivityList() {
|
||||
</Title>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{events.map((event, index) => (
|
||||
{events.map((event) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={`${event.title}-${event.date}`}
|
||||
style={{
|
||||
borderLeft: "4px solid var(--mantine-color-blue-filled)",
|
||||
paddingLeft: 12,
|
||||
|
||||
@@ -45,8 +45,8 @@ export function ChartAPBDes() {
|
||||
Grafik APBDes
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{apbdesData.map((item, index) => (
|
||||
<Group key={index} align="center" gap="md">
|
||||
{apbdesData.map((item) => (
|
||||
<Group key={item.name} align="center" gap="md">
|
||||
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
|
||||
@@ -45,8 +45,8 @@ export function DivisionProgress() {
|
||||
Divisi Teraktif
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{divisionData.map((divisi, index) => (
|
||||
<Box key={index}>
|
||||
{divisionData.map((divisi) => (
|
||||
<Box key={divisi.name}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
|
||||
{divisi.name}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
@@ -51,8 +50,8 @@ export function SatisfactionChart() {
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{satisfactionData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
{satisfactionData.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
@@ -65,8 +64,8 @@ export function SatisfactionChart() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Group justify="center" gap="md" mt="md">
|
||||
{satisfactionData.map((item, index) => (
|
||||
<Group key={index} gap="xs">
|
||||
{satisfactionData.map((item) => (
|
||||
<Group key={item.name} gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
h={12}
|
||||
|
||||
220
src/components/dev-inspector.tsx
Normal file
220
src/components/dev-inspector.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface CodeInfo {
|
||||
relativePath: string;
|
||||
line: string;
|
||||
column: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts data-inspector-* from fiber props or DOM attributes.
|
||||
* Handles React 19 fiber tree walk-up and DOM attribute fallbacks.
|
||||
*/
|
||||
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
|
||||
// Strategy 1: React internal props __reactProps$ (most accurate in R19)
|
||||
for (const key of Object.keys(element)) {
|
||||
if (key.startsWith("__reactProps$")) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||
const props = (element as any)[key];
|
||||
if (props?.["data-inspector-relative-path"]) {
|
||||
return {
|
||||
relativePath: props["data-inspector-relative-path"],
|
||||
line: props["data-inspector-line"] || "1",
|
||||
column: props["data-inspector-column"] || "1",
|
||||
};
|
||||
}
|
||||
}
|
||||
// Strategy 2: Walk fiber tree __reactFiber$
|
||||
if (key.startsWith("__reactFiber$")) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: React internals
|
||||
let f = (element as any)[key];
|
||||
while (f) {
|
||||
const p = f.pendingProps || f.memoizedProps;
|
||||
if (p?.["data-inspector-relative-path"]) {
|
||||
return {
|
||||
relativePath: p["data-inspector-relative-path"],
|
||||
line: p["data-inspector-line"] || "1",
|
||||
column: p["data-inspector-column"] || "1",
|
||||
};
|
||||
}
|
||||
// Fallback: _debugSource (React < 19)
|
||||
const src = f._debugSource ?? f._debugOwner?._debugSource;
|
||||
if (src?.fileName && src?.lineNumber) {
|
||||
return {
|
||||
relativePath: src.fileName,
|
||||
line: String(src.lineNumber),
|
||||
column: String(src.columnNumber ?? 1),
|
||||
};
|
||||
}
|
||||
f = f.return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Universal DOM attribute fallback
|
||||
const rp = element.getAttribute("data-inspector-relative-path");
|
||||
if (rp) {
|
||||
return {
|
||||
relativePath: rp,
|
||||
line: element.getAttribute("data-inspector-line") || "1",
|
||||
column: element.getAttribute("data-inspector-column") || "1",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Walks up DOM tree until source info is found. */
|
||||
function findCodeInfo(target: HTMLElement): CodeInfo | null {
|
||||
let el: HTMLElement | null = target;
|
||||
while (el) {
|
||||
const info = getCodeInfoFromElement(el);
|
||||
if (info) return info;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openInEditor(info: CodeInfo) {
|
||||
fetch("/__open-in-editor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
relativePath: info.relativePath,
|
||||
lineNumber: info.line,
|
||||
columnNumber: info.column,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function DevInspector({ children }: { children: React.ReactNode }) {
|
||||
const [active, setActive] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastInfoRef = useRef<CodeInfo | null>(null);
|
||||
|
||||
const updateOverlay = useCallback((target: HTMLElement | null) => {
|
||||
const ov = overlayRef.current;
|
||||
const tt = tooltipRef.current;
|
||||
if (!ov || !tt) return;
|
||||
|
||||
if (!target) {
|
||||
ov.style.display = "none";
|
||||
tt.style.display = "none";
|
||||
lastInfoRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const info = findCodeInfo(target);
|
||||
if (!info) {
|
||||
ov.style.display = "none";
|
||||
tt.style.display = "none";
|
||||
lastInfoRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
lastInfoRef.current = info;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
ov.style.display = "block";
|
||||
ov.style.top = `${rect.top + window.scrollY}px`;
|
||||
ov.style.left = `${rect.left + window.scrollX}px`;
|
||||
ov.style.width = `${rect.width}px`;
|
||||
ov.style.height = `${rect.height}px`;
|
||||
|
||||
tt.style.display = "block";
|
||||
tt.textContent = `${info.relativePath}:${info.line}`;
|
||||
const ttTop = rect.top + window.scrollY - 24;
|
||||
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`;
|
||||
tt.style.left = `${rect.left + window.scrollX}px`;
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: updateOverlay is stable
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const onMouseOver = (e: MouseEvent) =>
|
||||
updateOverlay(e.target as HTMLElement);
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement);
|
||||
if (info) {
|
||||
const loc = `${info.relativePath}:${info.line}:${info.column}`;
|
||||
console.log("[DevInspector] Open:", loc);
|
||||
openInEditor(info);
|
||||
}
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setActive(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseover", onMouseOver, true);
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.cursor = "crosshair";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseover", onMouseOver, true);
|
||||
document.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.cursor = "";
|
||||
if (overlayRef.current) overlayRef.current.style.display = "none";
|
||||
if (tooltipRef.current) tooltipRef.current.style.display = "none";
|
||||
};
|
||||
}, [active, updateOverlay]);
|
||||
|
||||
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key.toLowerCase() === "c" &&
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
(e.metaKey || e.altKey)
|
||||
) {
|
||||
e.preventDefault();
|
||||
setActive((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
display: "none",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
border: "2px solid #3b82f6",
|
||||
backgroundColor: "rgba(59,130,246,0.1)",
|
||||
zIndex: 99999,
|
||||
transition: "all 0.05s ease",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
display: "none",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
backgroundColor: "#1e293b",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "12px",
|
||||
fontFamily: "monospace",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "3px",
|
||||
zIndex: 100000,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -21,44 +20,10 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export function Header({ onSidebarToggle }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const _location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Define page titles based on route
|
||||
const getPageTitle = () => {
|
||||
switch (location.pathname) {
|
||||
case "/":
|
||||
return "Beranda";
|
||||
case "/kinerja-divisi":
|
||||
return "Kinerja Divisi";
|
||||
case "/pengaduan-layanan-publik":
|
||||
return "Pengaduan & Layanan Publik";
|
||||
case "/jenna-analytic":
|
||||
return "Jenna Analytic";
|
||||
case "/demografi-pekerjaan":
|
||||
return "Demografi & Kependudukan";
|
||||
case "/keuangan-anggaran":
|
||||
return "Keuangan & Anggaran";
|
||||
case "/bumdes":
|
||||
return "Bumdes & UMKM Desa";
|
||||
case "/sosial":
|
||||
return "Sosial";
|
||||
case "/keamanan":
|
||||
return "Keamanan";
|
||||
case "/bantuan":
|
||||
return "Bantuan";
|
||||
case "/pengaturan":
|
||||
case "/pengaturan/umum":
|
||||
case "/pengaturan/notifikasi":
|
||||
case "/pengaturan/keamanan":
|
||||
case "/pengaturan/akses-dan-tim":
|
||||
return "Pengaturan";
|
||||
default:
|
||||
return "Desa Darmasaba";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" w="100%">
|
||||
@@ -77,9 +42,6 @@ export function Header({ onSidebarToggle }: HeaderProps) {
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
{/* <Title order={3} c={"white"}>
|
||||
{getPageTitle()}
|
||||
</Title> */}
|
||||
</Group>
|
||||
|
||||
{/* Right Section */}
|
||||
|
||||
@@ -152,9 +152,9 @@ const HelpPage = () => {
|
||||
|
||||
{/* Statistics Section */}
|
||||
<SimpleGrid cols={3} spacing="lg" mb="xl">
|
||||
{stats.map((stat, index) => (
|
||||
{stats.map((stat) => (
|
||||
<HelpCard
|
||||
key={index}
|
||||
key={stat.label}
|
||||
bg={dark ? "#1E293B" : "white"}
|
||||
p="lg"
|
||||
style={{
|
||||
@@ -192,9 +192,9 @@ const HelpPage = () => {
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{guideItems.map((item, index) => (
|
||||
{guideItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -226,9 +226,9 @@ const HelpPage = () => {
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{videoItems.map((item, index) => (
|
||||
{videoItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -260,13 +260,13 @@ const HelpPage = () => {
|
||||
h="100%"
|
||||
>
|
||||
<Accordion variant="separated">
|
||||
{faqItems.map((item, index) => (
|
||||
{faqItems.map((item) => (
|
||||
<Accordion.Item
|
||||
style={{
|
||||
backgroundColor: dark ? "#263852ff" : "#F1F5F9",
|
||||
}}
|
||||
key={index}
|
||||
value={`faq-${index}`}
|
||||
key={item.question}
|
||||
value={item.question}
|
||||
>
|
||||
<Accordion.Control>{item.question}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
@@ -335,9 +335,9 @@ const HelpPage = () => {
|
||||
h="100%"
|
||||
>
|
||||
<Box>
|
||||
{documentationItems.map((item, index) => (
|
||||
{documentationItems.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.title}
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
@@ -434,6 +434,7 @@ const HelpPage = () => {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendMessage}
|
||||
disabled={isLoading || inputValue.trim() === ""}
|
||||
style={{
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
@@ -224,9 +223,9 @@ const JennaAnalytic = () => {
|
||||
Topik Pertanyaan Terbanyak
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{topTopics.map((item, index) => (
|
||||
{topTopics.map((item) => (
|
||||
<Box
|
||||
key={index}
|
||||
key={item.topic}
|
||||
p="sm"
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
@@ -270,8 +269,8 @@ const JennaAnalytic = () => {
|
||||
Jam Tersibuk
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
{busyHours.map((item, index) => (
|
||||
<Box key={index}>
|
||||
{busyHours.map((item) => (
|
||||
<Box key={item.period}>
|
||||
<Group justify="space-between" mb={5}>
|
||||
<Text size="sm" fw={500} c={dark ? "white" : "gray.9"}>
|
||||
{item.period}
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCamera,
|
||||
IconClock,
|
||||
IconMapPin
|
||||
IconMapPin,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const KeamananPage = () => {
|
||||
@@ -120,8 +120,8 @@ const KeamananPage = () => {
|
||||
<Stack gap={"xs"}>
|
||||
{/* KPI Cards */}
|
||||
<Grid gutter="md">
|
||||
{kpiData.map((kpi, index) => (
|
||||
<GridCol key={index} span={{ base: 12, sm: 6, md: 6 }}>
|
||||
{kpiData.map((kpi) => (
|
||||
<GridCol key={kpi.title} span={{ base: 12, sm: 6, md: 6 }}>
|
||||
<Card
|
||||
p="md"
|
||||
radius="md"
|
||||
@@ -214,9 +214,9 @@ const KeamananPage = () => {
|
||||
<Title order={4} c={dark ? "dark.0" : "black"}>
|
||||
Daftar CCTV
|
||||
</Title>
|
||||
{cctvLocations.map((cctv, index) => (
|
||||
{cctvLocations.map((cctv) => (
|
||||
<Card
|
||||
key={index}
|
||||
key={cctv.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
@@ -269,9 +269,9 @@ const KeamananPage = () => {
|
||||
h="100%"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{securityReports.map((report, index) => (
|
||||
{securityReports.map((report) => (
|
||||
<Card
|
||||
key={index}
|
||||
key={report.id}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
import { Card, Grid, Stack } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { ActivityCard } from "./kinerja-divisi/activity-card";
|
||||
import { ArchiveCard } from "./kinerja-divisi/archive-card";
|
||||
import { DiscussionPanel } from "./kinerja-divisi/discussion-panel";
|
||||
@@ -7,34 +10,6 @@ import { DocumentChart } from "./kinerja-divisi/document-chart";
|
||||
import { EventCard } from "./kinerja-divisi/event-card";
|
||||
import { ProgressChart } from "./kinerja-divisi/progress-chart";
|
||||
|
||||
// Data for program kegiatan (Section 1)
|
||||
const programKegiatanData = [
|
||||
{
|
||||
title: "Rakor 2025",
|
||||
date: "3 Juli 2025",
|
||||
progress: 90,
|
||||
status: "Selesai" as const,
|
||||
},
|
||||
{
|
||||
title: "Pemutakhiran Indeks Desa",
|
||||
date: "3 Juli 2025",
|
||||
progress: 85,
|
||||
status: "Selesai" as const,
|
||||
},
|
||||
{
|
||||
title: "Mengurus Akta Cerai Warga",
|
||||
date: "3 Juli 2025",
|
||||
progress: 80,
|
||||
status: "Selesai" as const,
|
||||
},
|
||||
{
|
||||
title: "Pasek 7 Desa Adat",
|
||||
date: "3 Juli 2025",
|
||||
progress: 92,
|
||||
status: "Selesai" as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Data for arsip digital (Section 5)
|
||||
const archiveData = [
|
||||
{ name: "Surat Keputusan" },
|
||||
@@ -44,20 +19,70 @@ const archiveData = [
|
||||
];
|
||||
|
||||
const KinerjaDivisi = () => {
|
||||
const [activities, setActivities] = useState<any[]>([]);
|
||||
const [todayEvents, setTodayEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [activityRes, eventRes] = await Promise.all([
|
||||
apiClient.GET("/api/division/activities"),
|
||||
apiClient.GET("/api/event/today"),
|
||||
]);
|
||||
|
||||
if (activityRes.data?.data) {
|
||||
setActivities(activityRes.data.data);
|
||||
}
|
||||
if (eventRes.data?.data) {
|
||||
setTodayEvents(eventRes.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch kinerja divisi data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Format events for EventCard
|
||||
const formattedEvents = todayEvents.map((event) => ({
|
||||
time: dayjs(event.startDate).format("HH:mm"),
|
||||
event: event.title,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* SECTION 1 — PROGRAM KEGIATAN */}
|
||||
<Grid gutter="md">
|
||||
{programKegiatanData.map((kegiatan, index) => (
|
||||
<Grid.Col key={index} span={{ base: 12, md: 6, lg: 3 }}>
|
||||
{activities.slice(0, 4).map((kegiatan, index) => (
|
||||
<Grid.Col
|
||||
key={kegiatan.id || index}
|
||||
span={{ base: 12, md: 6, lg: 3 }}
|
||||
>
|
||||
<ActivityCard
|
||||
title={kegiatan.title}
|
||||
date={kegiatan.date}
|
||||
date={dayjs(kegiatan.createdAt).format("D MMMM YYYY")}
|
||||
progress={kegiatan.progress}
|
||||
status={kegiatan.status}
|
||||
status={
|
||||
kegiatan.status === "SELESAI"
|
||||
? "Selesai"
|
||||
: kegiatan.status === "BERJALAN"
|
||||
? "Berjalan"
|
||||
: "Tertunda"
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
{!loading && activities.length === 0 && (
|
||||
<Grid.Col span={12}>
|
||||
<Card p="md" radius="xl" withBorder ta="center" c="dimmed">
|
||||
Tidak ada aktivitas terbaru
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* SECTION 2 — GRID DASHBOARD (3 Columns) */}
|
||||
@@ -82,7 +107,7 @@ const KinerjaDivisi = () => {
|
||||
<DiscussionPanel />
|
||||
|
||||
{/* SECTION 4 — ACARA HARI INI */}
|
||||
<EventCard />
|
||||
<EventCard agendas={formattedEvents} />
|
||||
|
||||
{/* SECTION 5 — ARSIP DIGITAL PERANGKAT DESA */}
|
||||
<Grid gutter="md">
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Box, Card, Group, Progress, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Progress,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface ActivityCardProps {
|
||||
title: string;
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface DivisionItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const divisionData: DivisionItem[] = [
|
||||
{ name: "Kesejahteraan", count: 37 },
|
||||
{ name: "Pemerintahan", count: 26 },
|
||||
{ name: "Keuangan", count: 17 },
|
||||
{ name: "Sekretaris Desa", count: 15 },
|
||||
{ name: "Tata Usaha TK", count: 14 },
|
||||
{ name: "Perangkat Kewilayahan", count: 12 },
|
||||
{ name: "Pelayanan", count: 10 },
|
||||
{ name: "Perencanaan", count: 9 },
|
||||
{ name: "Tata Usaha & Umum", count: 7 },
|
||||
];
|
||||
|
||||
export function DivisionList() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [divisions, setDivisions] = useState<DivisionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDivisions() {
|
||||
try {
|
||||
const { data } = await apiClient.GET("/api/division/");
|
||||
if (data?.data) {
|
||||
const mapped = data.data.map((div: { name: string; _count?: { activities: number } }) => ({
|
||||
name: div.name,
|
||||
count: div._count?.activities || 0,
|
||||
}));
|
||||
setDivisions(mapped);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch divisions", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDivisions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
@@ -47,30 +61,40 @@ export function DivisionList() {
|
||||
Divisi Teraktif
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{divisionData.map((division, index) => (
|
||||
<Group
|
||||
key={index}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
backgroundColor: dark ? "#334155" : "#F1F5F9",
|
||||
transition: "background-color 0.2s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.count}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
|
||||
</Group>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
))}
|
||||
) : divisions.length > 0 ? (
|
||||
divisions.map((division, index) => (
|
||||
<Group
|
||||
key={index}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
backgroundColor: dark ? "#334155" : "#F1F5F9",
|
||||
transition: "background-color 0.2s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"}>
|
||||
{division.count}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={dark ? "#94A3B8" : "#64748B"} />
|
||||
</Group>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Tidak ada data divisi
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,17 @@ import {
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { CheckCircle, Clock, FileText, MessageCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -22,40 +26,11 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
// Summary data
|
||||
const summaryData = [
|
||||
{
|
||||
title: "Total Pengaduan",
|
||||
value: 42,
|
||||
subtitle: "Bulan ini",
|
||||
icon: MessageCircle,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Baru",
|
||||
value: 14,
|
||||
subtitle: "Belum diproses",
|
||||
icon: FileText,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Diproses",
|
||||
value: 14,
|
||||
subtitle: "Sedang ditangani",
|
||||
icon: Clock,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Selesai",
|
||||
value: 14,
|
||||
subtitle: "Terselesaikan",
|
||||
icon: CheckCircle,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
];
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// Tren pengaduan data
|
||||
// Tren pengaduan data (Mock for now)
|
||||
const trenData = [
|
||||
{ bulan: "Apr", jumlah: 35 },
|
||||
{ bulan: "Mei", jumlah: 48 },
|
||||
@@ -66,50 +41,7 @@ const trenData = [
|
||||
{ bulan: "Okt", jumlah: 52 },
|
||||
];
|
||||
|
||||
// Surat terbanyak data
|
||||
const suratData = [
|
||||
{ jenis: "KTP", jumlah: 24 },
|
||||
{ jenis: "KK", jumlah: 18 },
|
||||
{ jenis: "Domisili", jumlah: 15 },
|
||||
{ jenis: "Usaha", jumlah: 12 },
|
||||
{ jenis: "Lainnya", jumlah: 8 },
|
||||
];
|
||||
|
||||
// Pengajuan terbaru data
|
||||
const pengajuanTerbaru = [
|
||||
{
|
||||
nama: "Budi Santoso",
|
||||
jenis: "Ketertiban Umum",
|
||||
waktu: "2 jam yang lalu",
|
||||
status: "baru",
|
||||
},
|
||||
{
|
||||
nama: "Siti Rahayu",
|
||||
jenis: "Pelayanan Kesehatan",
|
||||
waktu: "5 jam yang lalu",
|
||||
status: "proses",
|
||||
},
|
||||
{
|
||||
nama: "Ahmad Fauzi",
|
||||
jenis: "Infrastruktur",
|
||||
waktu: "1 hari yang lalu",
|
||||
status: "selesai",
|
||||
},
|
||||
{
|
||||
nama: "Dewi Lestari",
|
||||
jenis: "Administrasi",
|
||||
waktu: "1 hari yang lalu",
|
||||
status: "baru",
|
||||
},
|
||||
{
|
||||
nama: "Joko Widodo",
|
||||
jenis: "Keamanan",
|
||||
waktu: "2 hari yang lalu",
|
||||
status: "proses",
|
||||
},
|
||||
];
|
||||
|
||||
// Ide inovatif data
|
||||
// Ide inovatif data (Mock for now)
|
||||
const ideInovatif = [
|
||||
{
|
||||
nama: "Andi Prasetyo",
|
||||
@@ -123,24 +55,13 @@ const ideInovatif = [
|
||||
waktu: "5 hari yang lalu",
|
||||
kategori: "Ekonomi",
|
||||
},
|
||||
{
|
||||
nama: "Bambang Suryono",
|
||||
judul: "Peningkatan Sanitasi",
|
||||
waktu: "1 minggu yang lalu",
|
||||
kategori: "Kesehatan",
|
||||
},
|
||||
{
|
||||
nama: "Lina Marlina",
|
||||
judul: "Pusat Kreatif Anak Muda",
|
||||
waktu: "2 minggu yang lalu",
|
||||
kategori: "Pendidikan",
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case "baru":
|
||||
return "red";
|
||||
case "diproses":
|
||||
case "proses":
|
||||
return "blue";
|
||||
case "selesai":
|
||||
@@ -154,6 +75,75 @@ const PengaduanLayananPublik = () => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
baru: 0,
|
||||
proses: 0,
|
||||
selesai: 0,
|
||||
});
|
||||
const [recentComplaints, setRecentComplaints] = useState<any[]>([]);
|
||||
const [serviceStats, setServiceStats] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [statsRes, recentRes, serviceRes] = await Promise.all([
|
||||
apiClient.GET("/api/complaint/stats"),
|
||||
apiClient.GET("/api/complaint/recent"),
|
||||
apiClient.GET("/api/complaint/service-stats"),
|
||||
]);
|
||||
|
||||
if (statsRes.data?.data) setStats(statsRes.data.data);
|
||||
if (recentRes.data?.data) setRecentComplaints(recentRes.data.data);
|
||||
if (serviceRes.data?.data) {
|
||||
const mappedService = serviceRes.data.data.map((item: any) => ({
|
||||
jenis: item.letterType,
|
||||
jumlah: item._count?._all || 0,
|
||||
}));
|
||||
setServiceStats(mappedService);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch complaint data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const summaryData = [
|
||||
{
|
||||
title: "Total Pengaduan",
|
||||
value: stats.total,
|
||||
subtitle: "Bulan ini",
|
||||
icon: MessageCircle,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Baru",
|
||||
value: stats.baru,
|
||||
subtitle: "Belum diproses",
|
||||
icon: FileText,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Diproses",
|
||||
value: stats.proses,
|
||||
subtitle: "Sedang ditangani",
|
||||
icon: Clock,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
{
|
||||
title: "Selesai",
|
||||
value: stats.selesai,
|
||||
subtitle: "Terselesaikan",
|
||||
icon: CheckCircle,
|
||||
color: "#1E3A5F",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* TOP SECTION - 4 STAT CARDS */}
|
||||
@@ -178,7 +168,7 @@ const PengaduanLayananPublik = () => {
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c={dark ? "white" : "gray.9"}>
|
||||
{item.value}
|
||||
{loading ? <Loader size="xs" /> : item.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.subtitle}
|
||||
@@ -189,9 +179,6 @@ const PengaduanLayananPublik = () => {
|
||||
variant="filled"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
style={{
|
||||
transition: "transform 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<item.icon style={{ width: "60%", height: "60%" }} />
|
||||
</ThemeIcon>
|
||||
@@ -278,35 +265,45 @@ const PengaduanLayananPublik = () => {
|
||||
Surat Terbanyak
|
||||
</Title>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={suratData} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="jenis"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="jumlah" fill="#396aaaff" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
{loading ? (
|
||||
<Group justify="center" align="center" h="100%">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<BarChart data={serviceStats} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={false}
|
||||
stroke={dark ? "#334155" : "#e5e7eb"}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="jenis"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="jumlah"
|
||||
fill="#396aaaff"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
@@ -328,42 +325,52 @@ const PengaduanLayananPublik = () => {
|
||||
Pengajuan Terbaru
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
{pengajuanTerbaru.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
p="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
borderColor: "transparent",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{item.nama}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.jenis}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Badge
|
||||
color={getStatusColor(item.status)}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.waktu}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : recentComplaints.length > 0 ? (
|
||||
recentComplaints.map((item, index) => (
|
||||
<Card
|
||||
key={item.id || index}
|
||||
p="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={dark ? "#334155" : "#F1F5F9"}
|
||||
style={{
|
||||
borderColor: "transparent",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} c={dark ? "white" : "gray.9"}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.category}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Badge
|
||||
color={getStatusColor(item.status)}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{dayjs(item.createdAt).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Text c="dimmed" ta="center">
|
||||
Tidak ada pengajuan terbaru
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
Reference in New Issue
Block a user