feat(dashboard): connect dashboard components to database
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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%" }} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user