fix(header): fix missing Divider, Badge, IconUserShield and navigate

This commit is contained in:
2026-03-26 14:13:59 +08:00
parent ebc1242bee
commit aeedb17402
35 changed files with 2788 additions and 552 deletions

66
src/api/complaint.ts Normal file
View File

@@ -0,0 +1,66 @@
import Elysia from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const complaint = new Elysia({
prefix: "/complaint",
})
.get(
"/stats",
async ({ set }) => {
try {
const [total, baru, proses, selesai] = await Promise.all([
prisma.complaint.count(),
prisma.complaint.count({ where: { status: "BARU" } }),
prisma.complaint.count({ where: { status: "DIPROSES" } }),
prisma.complaint.count({ where: { status: "SELESAI" } }),
]);
return { data: { total, baru, proses, selesai } };
} catch (error) {
logger.error({ error }, "Failed to fetch complaint stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get complaint statistics" },
},
)
.get(
"/recent",
async ({ set }) => {
try {
const recent = await prisma.complaint.findMany({
orderBy: { createdAt: "desc" },
take: 10,
});
return { data: recent };
} catch (error) {
logger.error({ error }, "Failed to fetch recent complaints");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get recent complaints" },
},
)
.get(
"/service-stats",
async ({ set }) => {
try {
const serviceStats = await prisma.serviceLetter.groupBy({
by: ["letterType"],
_count: { _all: true },
});
return { data: serviceStats };
} catch (error) {
logger.error({ error }, "Failed to fetch service stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get service letter statistics by type" },
},
);

71
src/api/division.ts Normal file
View File

@@ -0,0 +1,71 @@
import Elysia from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const division = new Elysia({
prefix: "/division",
})
.get(
"/",
async ({ set }) => {
try {
const divisions = await prisma.division.findMany({
include: {
_count: {
select: { activities: true },
},
},
});
return { data: divisions };
} catch (error) {
logger.error({ error }, "Failed to fetch divisions");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get all divisions" },
},
)
.get(
"/activities",
async ({ set }) => {
try {
const activities = await prisma.activity.findMany({
include: {
division: {
select: { name: true, color: true },
},
},
orderBy: { createdAt: "desc" },
take: 10,
});
return { data: activities };
} catch (error) {
logger.error({ error }, "Failed to fetch activities");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get recent activities" },
},
)
.get(
"/metrics",
async ({ set }) => {
try {
const metrics = await prisma.divisionMetric.findMany({
include: { division: true },
});
return { data: metrics };
} catch (error) {
logger.error({ error }, "Failed to fetch division metrics");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get division performance metrics" },
},
);

54
src/api/event.ts Normal file
View File

@@ -0,0 +1,54 @@
import Elysia from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const event = new Elysia({
prefix: "/event",
})
.get(
"/",
async ({ set }) => {
try {
const events = await prisma.event.findMany({
orderBy: { startDate: "asc" },
take: 20,
});
return { data: events };
} catch (error) {
logger.error({ error }, "Failed to fetch events");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get upcoming events" },
},
)
.get(
"/today",
async ({ set }) => {
try {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date();
end.setHours(23, 59, 59, 999);
const events = await prisma.event.findMany({
where: {
startDate: {
gte: start,
lte: end,
},
},
});
return { data: events };
} catch (error) {
logger.error({ error }, "Failed to fetch today's events");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get events for today" },
},
);

View File

@@ -4,7 +4,11 @@ import Elysia from "elysia";
import { apiMiddleware } from "../middleware/apiMiddleware";
import { auth } from "../utils/auth";
import { apikey } from "./apikey";
import { complaint } from "./complaint";
import { division } from "./division";
import { event } from "./event";
import { profile } from "./profile";
import { resident } from "./resident";
const isProduction = process.env.NODE_ENV === "production";
@@ -20,7 +24,11 @@ const api = new Elysia({
})
.use(apiMiddleware)
.use(apikey)
.use(profile);
.use(profile)
.use(division)
.use(complaint)
.use(resident)
.use(event);
if (!isProduction) {
api.use(

View File

@@ -2,12 +2,25 @@ import Elysia, { t } from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
interface AuthenticatedUser {
id: string;
email: string;
name?: string | null;
}
export const profile = new Elysia({
prefix: "/profile",
}).post(
"/update",
async (ctx) => {
const { body, set, user } = ctx as any;
async ({
body,
set,
user,
}: {
body: { name?: string; image?: string };
set: any;
user?: AuthenticatedUser;
}) => {
try {
if (!user) {
set.status = 401;

76
src/api/resident.ts Normal file
View File

@@ -0,0 +1,76 @@
import Elysia from "elysia";
import { prisma } from "../utils/db";
import logger from "../utils/logger";
export const resident = new Elysia({
prefix: "/resident",
})
.get(
"/stats",
async ({ set }) => {
try {
const [total, heads, poor] = await Promise.all([
prisma.resident.count(),
prisma.resident.count({ where: { isHeadOfHousehold: true } }),
prisma.resident.count({ where: { isPoor: true } }),
]);
return { data: { total, heads, poor } };
} catch (error) {
logger.error({ error }, "Failed to fetch resident stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get resident statistics" },
},
)
.get(
"/banjar-stats",
async ({ set }) => {
try {
const banjarStats = await prisma.banjar.findMany({
select: {
id: true,
name: true,
totalPopulation: true,
totalKK: true,
totalPoor: true,
},
});
return { data: banjarStats };
} catch (error) {
logger.error({ error }, "Failed to fetch banjar stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get population data per banjar" },
},
)
.get(
"/demographics",
async ({ set }) => {
try {
const [religion, gender] = await Promise.all([
prisma.resident.groupBy({
by: ["religion"],
_count: { _all: true },
}),
prisma.resident.groupBy({
by: ["gender"],
_count: { _all: true },
}),
]);
return { data: { religion, gender } };
} catch (error) {
logger.error({ error }, "Failed to fetch demographics");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get religious and gender demographics" },
},
);

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View 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",
}}
/>
</>
);
}

View File

@@ -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 */}

View File

@@ -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={{

View File

@@ -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}

View File

@@ -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

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -10,12 +10,11 @@
import { createTheme, MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { Inspector } from "react-dev-inspector";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import "./index.css";
import "@mantine/charts/styles.css";
import { IS_DEV, VITE_PUBLIC_URL } from "./utils/env";
import { IS_DEV } from "./utils/env";
// Create a new router instance
export const router = createRouter({
@@ -101,29 +100,14 @@ const theme = createTheme({
primaryColor: "darmasaba-blue",
});
// Use dynamic import for DevInspector to avoid including it in production bundle
const InspectorWrapper = IS_DEV
? Inspector
? (await import("./components/dev-inspector")).DevInspector
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
const elem = document.getElementById("root")!;
const app = (
<InspectorWrapper
keys={["shift", "a"]}
onClickElement={(e) => {
if (!e.codeInfo) return;
const url = VITE_PUBLIC_URL;
fetch(`${url}/__open-in-editor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
relativePath: e.codeInfo.relativePath,
lineNumber: e.codeInfo.lineNumber,
columnNumber: e.codeInfo.columnNumber,
}),
});
}}
>
<InspectorWrapper>
<MantineProvider theme={theme} defaultColorScheme="auto">
<ModalsProvider>
<RouterProvider router={router} />

View File

@@ -6,7 +6,7 @@ import { Elysia } from "elysia";
import api from "./api";
import { openInEditor } from "./utils/open-in-editor";
const PORT = process.env.PORT || 3000;
const PORT = Number(process.env.PORT || 3000);
const isProduction = process.env.NODE_ENV === "production";
@@ -35,14 +35,16 @@ if (!isProduction) {
app.post("/__open-in-editor", ({ body }) => {
const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string;
lineNumber: number;
columnNumber: number;
lineNumber: string;
columnNumber: string;
};
const editor = (process.env.REACT_EDITOR || "code") as any;
openInEditor(relativePath, {
line: lineNumber,
column: columnNumber,
editor: "antigravity",
line: Number(lineNumber),
column: Number(columnNumber),
editor: editor,
});
return { ok: true };

View File

@@ -11,7 +11,7 @@ export const Route = createRootRoute({
// Apply protected route middleware for all routes
// The middleware will determine which routes are public vs protected
const context = await protectedRouteMiddleware({ location });
// Only set auth store if we have user data (for protected routes)
if (context?.user) {
authStore.user = context?.user as any;

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import type { Plugin } from "vite";
/**
* Vite Plugin to inject data-inspector-* attributes into JSX elements.
* This enables click-to-source functionality in the browser.
*/
export function inspectorPlugin(): Plugin {
const rootDir = process.cwd();
return {
name: "inspector-inject",
enforce: "pre",
transform(code, id) {
// Only process .tsx and .jsx files, skip node_modules
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes("node_modules"))
return null;
if (!code.includes("<")) return null;
const relativePath = path.relative(rootDir, id);
let modified = false;
const lines = code.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Match JSX opening tags: <Component, <div, or <item.icon
// Allow dots and hyphens in the tag name
const jsxPattern = /(<(?:[A-Za-z][a-zA-Z0-9.-]*))\b/g;
let match: RegExpExecArray | null = null;
// biome-ignore lint/suspicious/noAssignInExpressions: match loop
while ((match = jsxPattern.exec(line)) !== null) {
// Skip if character before `<` is an identifier char (likely a TypeScript generic)
const charBefore = match.index > 0 ? line[match.index - 1] : "";
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue;
const col = match.index + 1;
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`;
const insertPos = match.index + match[0].length;
line = line.slice(0, insertPos) + attr + line.slice(insertPos);
modified = true;
jsxPattern.lastIndex += attr.length;
}
result.push(line);
}
if (!modified) return null;
return result.join("\n");
},
};
}

View File

@@ -1,9 +1,9 @@
import path from "node:path";
import { inspectorServer } from "@react-dev-inspector/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-vite-plugin";
import react from "@vitejs/plugin-react";
import { createServer as createViteServer } from "vite";
import { inspectorPlugin } from "./utils/dev-inspector-plugin";
export async function createVite() {
return createViteServer({
@@ -14,23 +14,7 @@ export async function createVite() {
"@": path.resolve(process.cwd(), "./src"),
},
},
plugins: [
tailwindcss(),
react({
babel: {
plugins: [
[
"@react-dev-inspector/babel-plugin",
{
relativePath: true,
},
],
],
},
}),
inspectorServer(),
tanstackRouter(),
],
plugins: [tailwindcss(), inspectorPlugin(), react(), tanstackRouter()],
server: {
middlewareMode: true,
hmr: {