[darmasaba-dashboard][2026-03-27] feat: modular seeders and database-backed dashboard
- Split seeders into modular files per feature category - Added seed:auth, seed:demographics, seed:divisions, seed:services, seed:dashboard commands - Connected dashboard components to live database (Budget, SDGs, Satisfaction) - Added API endpoints: /api/dashboard/budget, /api/dashboard/sdgs, /api/dashboard/satisfaction - Updated prisma schema with dashboard metrics models - Added loading states to dashboard components - Fixed header navigation to /admin Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
72
src/api/dashboard.ts
Normal file
72
src/api/dashboard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { prisma } from "../utils/db";
|
||||
|
||||
export const dashboard = new Elysia({ prefix: "/dashboard" })
|
||||
.get(
|
||||
"/budget",
|
||||
async () => {
|
||||
const data = await prisma.budget.findMany({
|
||||
where: { fiscalYear: 2025 },
|
||||
orderBy: { category: "asc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
category: t.String(),
|
||||
amount: t.Number(),
|
||||
percentage: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/sdgs",
|
||||
async () => {
|
||||
const data = await prisma.sdgsScore.findMany({
|
||||
orderBy: { score: "desc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
title: t.String(),
|
||||
score: t.Number(),
|
||||
image: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/satisfaction",
|
||||
async () => {
|
||||
const data = await prisma.satisfactionRating.findMany({
|
||||
orderBy: { value: "desc" },
|
||||
});
|
||||
return { data };
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
category: t.String(),
|
||||
value: t.Number(),
|
||||
color: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { apiMiddleware } from "../middleware/apiMiddleware";
|
||||
import { auth } from "../utils/auth";
|
||||
import { apikey } from "./apikey";
|
||||
import { complaint } from "./complaint";
|
||||
import { dashboard } from "./dashboard";
|
||||
import { division } from "./division";
|
||||
import { event } from "./event";
|
||||
import { profile } from "./profile";
|
||||
@@ -40,7 +41,8 @@ const api = new Elysia({
|
||||
.use(division)
|
||||
.use(complaint)
|
||||
.use(resident)
|
||||
.use(event);
|
||||
.use(event)
|
||||
.use(dashboard);
|
||||
|
||||
if (!isProduction) {
|
||||
api.use(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Grid, Image, Stack } from "@mantine/core";
|
||||
import { Grid, Image, Loader, Stack, Center } from "@mantine/core";
|
||||
import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
@@ -10,29 +10,6 @@ import { SatisfactionChart } from "./dashboard/satisfaction-chart";
|
||||
import { SDGSCard } from "./dashboard/sdgs-card";
|
||||
import { StatCard } from "./dashboard/stat-card";
|
||||
|
||||
const sdgsData = [
|
||||
{
|
||||
title: "Desa Berenergi Bersih dan Terbarukan",
|
||||
score: 99.64,
|
||||
image: "SDGS-7.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Damai Berkeadilan",
|
||||
score: 78.65,
|
||||
image: "SDGS-16.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Sehat dan Sejahtera",
|
||||
score: 77.37,
|
||||
image: "SDGS-3.png",
|
||||
},
|
||||
{
|
||||
title: "Desa Tanpa Kemiskinan",
|
||||
score: 52.62,
|
||||
image: "SDGS-1.png",
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardContent() {
|
||||
const [stats, setStats] = useState({
|
||||
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
||||
@@ -41,14 +18,18 @@ export function DashboardContent() {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const [sdgsData, setSdgsData] = useState<{ title: string; score: number; image: string | null }[]>([]);
|
||||
const [sdgsLoading, setSdgsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const [complaintRes, residentRes, weeklyServiceRes] = await Promise.all(
|
||||
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] = await Promise.all(
|
||||
[
|
||||
apiClient.GET("/api/complaint/stats"),
|
||||
apiClient.GET("/api/resident/stats"),
|
||||
apiClient.GET("/api/complaint/service-weekly"),
|
||||
apiClient.GET("/api/dashboard/sdgs"),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -71,9 +52,15 @@ export function DashboardContent() {
|
||||
?.count || 0,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
if (sdgsRes.data?.data) {
|
||||
setSdgsData(sdgsRes.data.data);
|
||||
}
|
||||
setSdgsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats", error);
|
||||
console.error("Failed to fetch dashboard content", error);
|
||||
setStats((prev) => ({ ...prev, loading: false }));
|
||||
setSdgsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,17 +133,23 @@ export function DashboardContent() {
|
||||
<ChartAPBDes />
|
||||
|
||||
{/* Section 6: SDGs Desa Cards */}
|
||||
<Grid gutter="md">
|
||||
{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}
|
||||
score={sdg.score}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
{sdgsLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Grid gutter="md">
|
||||
{sdgsData.map((sdg) => (
|
||||
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
||||
<SDGSCard
|
||||
image={sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null}
|
||||
title={sdg.title}
|
||||
score={sdg.score}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -15,18 +17,44 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
const apbdesData = [
|
||||
{ name: "Belanja", value: 70, color: "#3B82F6" },
|
||||
{ name: "Pangan", value: 45, color: "#22C55E" },
|
||||
{ name: "Pembiayaan", value: 55, color: "#FACC15" },
|
||||
{ name: "Pendapatan", value: 90, color: "#3B82F6" },
|
||||
];
|
||||
interface ApbdesData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ChartAPBDes() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<ApbdesData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchApbdes() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/dashboard/budget");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
res.data.data.map((d) => ({
|
||||
name: d.category,
|
||||
value: d.percentage,
|
||||
color: d.color,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch APBDes data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchApbdes();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
@@ -45,33 +73,39 @@ export function ChartAPBDes() {
|
||||
Grafik APBDes
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{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>
|
||||
<ResponsiveContainer width="100%" height={20}>
|
||||
<BarChart layout="vertical" data={[item]}>
|
||||
<XAxis type="number" hide domain={[0, 100]} />
|
||||
<YAxis type="category" hide dataKey="name" />
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => [
|
||||
`${value}%`,
|
||||
"",
|
||||
]}
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 4, 4]}>
|
||||
<Cell fill={item.color} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
))}
|
||||
) : data.length > 0 ? (
|
||||
data.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>
|
||||
<ResponsiveContainer width="100%" height={12} style={{ flex: 1 }}>
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={[item]}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, 100]} />
|
||||
<YAxis type="category" hide dataKey="name" />
|
||||
<Bar dataKey="value" radius={[10, 10, 10, 10]} barSize={12}>
|
||||
<Cell fill={item.color} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<Text size="sm" fw={600} w={40} ta="right" c={dark ? "white" : "gray.9"}>
|
||||
{item.value}%
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Tidak ada data APBDes
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,23 +2,51 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
const satisfactionData = [
|
||||
{ name: "Sangat Puas", value: 25, color: "#4E5BA6" },
|
||||
{ name: "Puas", value: 25, color: "#F4C542" },
|
||||
{ name: "Cukup", value: 25, color: "#8CC63F" },
|
||||
{ name: "Kurang", value: 25, color: "#E57373" },
|
||||
];
|
||||
interface SatisfactionData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function SatisfactionChart() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const [data, setData] = useState<SatisfactionData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSatisfaction() {
|
||||
try {
|
||||
const res = await apiClient.GET("/api/dashboard/satisfaction");
|
||||
if (res.data?.data) {
|
||||
setData(
|
||||
res.data.data.map((d) => ({
|
||||
name: d.category,
|
||||
value: d.value,
|
||||
color: d.color,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch satisfaction data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchSatisfaction();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
@@ -40,31 +68,37 @@ export function SatisfactionChart() {
|
||||
Tingkat kepuasan layanan
|
||||
</Text>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={satisfactionData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={80}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{satisfactionData.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
{loading ? (
|
||||
<Group justify="center" align="center" h="100%">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={80}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: dark ? "#1E293B" : "white",
|
||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
<Group justify="center" gap="md" mt="md">
|
||||
{satisfactionData.map((item) => (
|
||||
{data.map((item) => (
|
||||
<Group key={item.name} gap="xs">
|
||||
<Box
|
||||
w={12}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function Header({ onSidebarToggle }: HeaderProps) {
|
||||
<IconUserShield
|
||||
color="white"
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
onClick={() => navigate({ to: "/signin" })}
|
||||
onClick={() => navigate({ to: "/admin" })}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user