feat(dashboard): connect dashboard components to database

This commit is contained in:
2026-03-26 14:28:09 +08:00
parent 0900b8f199
commit ec057ef2e5
7 changed files with 329 additions and 98 deletions

View File

@@ -239,6 +239,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/complaint/service-trends": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get service letter trends for last 6 months */
get: operations["getApiComplaintService-trends"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/complaint/service-weekly": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get service letter count for current week */
get: operations["getApiComplaintService-weekly"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/resident/stats": {
parameters: {
query?: never;
@@ -966,6 +1000,40 @@ export interface operations {
};
};
};
"getApiComplaintService-trends": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiComplaintService-weekly": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiResidentStats: {
parameters: {
query?: never;

View File

@@ -1477,6 +1477,24 @@
}
}
},
"/api/complaint/service-trends": {
"get": {
"operationId": "getApiComplaintService-trends",
"summary": "Get service letter trends for last 6 months",
"responses": {
"200": {}
}
}
},
"/api/complaint/service-weekly": {
"get": {
"operationId": "getApiComplaintService-weekly",
"summary": "Get service letter count for current week",
"responses": {
"200": {}
}
}
},
"/api/resident/stats": {
"get": {
"operationId": "getApiResidentStats",

View File

@@ -82,4 +82,56 @@ export const complaint = new Elysia({
{
detail: { summary: "Get recent innovation ideas" },
},
)
.get(
"/service-trends",
async ({ set }) => {
try {
// Get last 6 months trends for service letters
const trends = await prisma.$queryRaw<any[]>`
SELECT
TO_CHAR("createdAt", 'Mon') as month,
EXTRACT(MONTH FROM "createdAt") as month_num,
COUNT(*) as count
FROM service_letter
WHERE "createdAt" > NOW() - INTERVAL '6 months'
GROUP BY month, month_num
ORDER BY month_num ASC
`;
return { data: trends };
} catch (error) {
logger.error({ error }, "Failed to fetch service trends");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get service letter trends for last 6 months" },
},
)
.get(
"/service-weekly",
async ({ set }) => {
try {
const startOfWeek = new Date();
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
startOfWeek.setHours(0, 0, 0, 0);
const count = await prisma.serviceLetter.count({
where: {
createdAt: {
gte: startOfWeek,
},
},
});
return { data: { count } };
} catch (error) {
logger.error({ error }, "Failed to fetch weekly service stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get service letter count for current week" },
},
);

View File

@@ -37,16 +37,20 @@ export function DashboardContent() {
const [stats, setStats] = useState({
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
residents: { total: 0, heads: 0, poor: 0 },
weeklyService: 0,
loading: true,
});
useEffect(() => {
async function fetchStats() {
try {
const [complaintRes, residentRes] = await Promise.all([
apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/resident/stats"),
]);
const [complaintRes, residentRes, weeklyServiceRes] = await Promise.all(
[
apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/resident/stats"),
apiClient.GET("/api/complaint/service-weekly"),
],
);
setStats({
complaints: (complaintRes.data as any)?.data || {
@@ -60,6 +64,7 @@ export function DashboardContent() {
heads: 0,
poor: 0,
},
weeklyService: (weeklyServiceRes.data as any)?.data?.count || 0,
loading: false,
});
} catch (error) {
@@ -78,8 +83,8 @@ export function DashboardContent() {
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
<StatCard
title="Surat Minggu Ini"
value={0}
detail="Menunggu integrasi riil"
value={stats.weeklyService}
detail="Total surat diajukan"
trend="0%"
trendValue={0}
icon={<FileText style={{ width: "70%", height: "70%" }} />}

View File

@@ -2,28 +2,51 @@ import {
Box,
Card,
Group,
Loader,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import dayjs from "dayjs";
import { Calendar } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface EventData {
date: string;
title: string;
}
const events: EventData[] = [
{ date: "1 Oktober 2025", title: "Hari Kesaktian Pancasila" },
{ date: "15 Oktober 2025", title: "Davest" },
{ date: "19 Oktober 2025", title: "Rapat Koordinasi" },
];
export function ActivityList() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<EventData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchEvents() {
try {
const res = await apiClient.GET("/api/event/");
if (res.data?.data) {
setData(
(res.data.data as any[]).map((e) => ({
date: dayjs(e.startDate).format("D MMMM YYYY"),
title: e.title,
})),
);
}
} catch (error) {
console.error("Failed to fetch events", error);
} finally {
setLoading(false);
}
}
fetchEvents();
}, []);
return (
<Card
p="md"
@@ -48,22 +71,32 @@ export function ActivityList() {
</Title>
</Group>
<Stack gap="md">
{events.map((event) => (
<Box
key={`${event.title}-${event.date}`}
style={{
borderLeft: "4px solid var(--mantine-color-blue-filled)",
paddingLeft: 12,
}}
>
<Text size="sm" c="dimmed">
{event.date}
</Text>
<Text fw={500} c={dark ? "white" : "gray.9"}>
{event.title}
</Text>
</Box>
))}
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
data.map((event) => (
<Box
key={`${event.title}-${event.date}`}
style={{
borderLeft: "4px solid var(--mantine-color-blue-filled)",
paddingLeft: 12,
}}
>
<Text size="sm" c="dimmed">
{event.date}
</Text>
<Text fw={500} c={dark ? "white" : "gray.9"}>
{event.title}
</Text>
</Box>
))
) : (
<Text size="sm" c="dimmed" ta="center">
Tidak ada kegiatan mendatang
</Text>
)}
</Stack>
</Card>
);

View File

@@ -3,10 +3,12 @@ import {
Box,
Card,
Group,
Loader,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
@@ -16,20 +18,37 @@ import {
XAxis,
YAxis,
} from "recharts";
const chartData = [
{ month: "Jan", value: 150 },
{ month: "Feb", value: 165 },
{ month: "Mar", value: 195 },
{ month: "Apr", value: 160 },
{ month: "Mei", value: 205 },
{ month: "Jun", value: 185 },
];
import { apiClient } from "@/utils/api-client";
export function ChartSurat() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchTrends() {
try {
const res = await apiClient.GET("/api/complaint/service-trends");
if (res.data?.data) {
setData(
(res.data.data as any[]).map((d) => ({
month: d.month,
value: Number(d.count),
})),
);
}
} catch (error) {
console.error("Failed to fetch service trends", error);
} finally {
setLoading(false);
}
}
fetchTrends();
}, []);
return (
<Card
p="md"
@@ -72,39 +91,44 @@ export function ChartSurat() {
</ActionIcon>
</Group>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
axisLine={false}
tickLine={false}
ticks={[0, 55, 110, 165, 220]}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar
dataKey="value"
fill="var(--mantine-color-blue-filled)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<BarChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar
dataKey="value"
fill="var(--mantine-color-blue-filled)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
)}
</ResponsiveContainer>
</Card>
);

View File

@@ -2,31 +2,52 @@ import {
Box,
Card,
Group,
Loader,
Progress,
Stack,
Text,
Title,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
interface DivisionData {
name: string;
value: number;
}
const divisionData: DivisionData[] = [
{ name: "Kesejahteraan", value: 37 },
{ name: "Pemberdayaan", value: 26 },
{ name: "Keuangan", value: 17 },
{ name: "Sekretaris Desa", value: 15 },
];
const max_value = 37;
export function DivisionProgress() {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const [data, setData] = useState<DivisionData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDivisions() {
try {
const res = await apiClient.GET("/api/division/");
if (res.data?.data) {
setData(
(res.data.data as any[]).map((d) => ({
name: d.name,
value: d._count?.activities || 0,
})),
);
}
} catch (error) {
console.error("Failed to fetch division stats", error);
} finally {
setLoading(false);
}
}
fetchDivisions();
}, []);
const max_value = Math.max(...data.map((d) => d.value), 1);
return (
<Card
p="md"
@@ -45,25 +66,35 @@ export function DivisionProgress() {
Divisi Teraktif
</Title>
<Stack gap="sm">
{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}
</Text>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{divisi.value} Kegiatan
</Text>
</Group>
<Progress
value={(divisi.value / max_value) * 100}
size="sm"
radius="xl"
color="blue"
animated
/>
</Box>
))}
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : data.length > 0 ? (
data.map((divisi) => (
<Box key={divisi.name}>
<Group justify="space-between" mb={5}>
<Text size="sm" fw={500} c={dark ? "white" : "gray.7"}>
{divisi.name}
</Text>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{divisi.value} Kegiatan
</Text>
</Group>
<Progress
value={(divisi.value / max_value) * 100}
size="sm"
radius="xl"
color="blue"
animated
/>
</Box>
))
) : (
<Text size="sm" c="dimmed" ta="center">
Tidak ada data divisi
</Text>
)}
</Stack>
</Card>
);