feat: connect kinerja divisi components to live database

New API Endpoints (src/api/division.ts):
- GET /api/division/discussions - Fetch recent discussions with sender info
- GET /api/division/documents/stats - Fetch document counts by type
- GET /api/division/activities/stats - Fetch activity status breakdown with percentages

Components Connected to Database:
- discussion-panel.tsx: Now fetches from /api/division/discussions
- document-chart.tsx: Now fetches from /api/division/documents/stats
- progress-chart.tsx: Now fetches from /api/division/activities/stats

Features Added:
- Loading states with Loader component
- Empty states with friendly messages
- Date formatting using date-fns with Indonesian locale
- Real-time data from database instead of hardcoded values
- Proper TypeScript typing for API responses

Files changed:
- src/api/division.ts: Added 3 new API endpoints
- src/components/kinerja-divisi/discussion-panel.tsx
- src/components/kinerja-divisi/document-chart.tsx
- src/components/kinerja-divisi/progress-chart.tsx

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-27 15:43:58 +08:00
parent b77822f2dd
commit 75c7bc249e
4 changed files with 390 additions and 141 deletions

View File

@@ -64,15 +64,36 @@ export const division = new Elysia({
}, },
) )
.get( .get(
"/metrics", "/activities/stats",
async ({ set }) => { async ({ set }) => {
try { try {
const metrics = await prisma.divisionMetric.findMany({ // Get activity count by status
include: { division: true }, const [selesai, berjalan, tertunda, dibatalkan] = await Promise.all([
}); prisma.activity.count({ where: { status: "SELESAI" } }),
return { data: metrics }; prisma.activity.count({ where: { status: "BERJALAN" } }),
prisma.activity.count({ where: { status: "TERTUNDA" } }),
prisma.activity.count({ where: { status: "DIBATALKAN" } }),
]);
const total = selesai + berjalan + tertunda + dibatalkan;
// Calculate percentages
const percentages = {
selesai: total > 0 ? (selesai / total) * 100 : 0,
berjalan: total > 0 ? (berjalan / total) * 100 : 0,
tertunda: total > 0 ? (tertunda / total) * 100 : 0,
dibatalkan: total > 0 ? (dibatalkan / total) * 100 : 0,
};
return {
data: {
total,
counts: { selesai, berjalan, tertunda, dibatalkan },
percentages,
},
};
} catch (error) { } catch (error) {
logger.error({ error }, "Failed to fetch division metrics"); logger.error({ error }, "Failed to fetch activity stats");
set.status = 500; set.status = 500;
return { error: "Internal Server Error" }; return { error: "Internal Server Error" };
} }
@@ -80,10 +101,117 @@ export const division = new Elysia({
{ {
response: { response: {
200: t.Object({ 200: t.Object({
data: t.Array(t.Any()), data: t.Object({
total: t.Number(),
counts: t.Object({
selesai: t.Number(),
berjalan: t.Number(),
tertunda: t.Number(),
dibatalkan: t.Number(),
}),
percentages: t.Object({
selesai: t.Number(),
berjalan: t.Number(),
tertunda: t.Number(),
dibatalkan: t.Number(),
}),
}),
}), }),
500: t.Object({ error: t.String() }), 500: t.Object({ error: t.String() }),
}, },
detail: { summary: "Get division performance metrics" }, detail: { summary: "Get activity statistics by status" },
},
)
.get(
"/documents/stats",
async ({ set }) => {
try {
// Group documents by type
const [gambarCount, dokumenCount] = await Promise.all([
prisma.document.count({ where: { type: "Gambar" } }),
prisma.document.count({ where: { type: "Dokumen" } }),
]);
return {
data: [
{ name: "Gambar", jumlah: gambarCount, color: "#FACC15" },
{ name: "Dokumen", jumlah: dokumenCount, color: "#22C55E" },
],
};
} catch (error) {
logger.error({ error }, "Failed to fetch document stats");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
name: t.String(),
jumlah: t.Number(),
color: t.String(),
}),
),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get document statistics by type" },
},
)
.get(
"/discussions",
async ({ set }) => {
try {
// Get recent discussions with sender info
const discussions = await prisma.discussion.findMany({
where: { parentId: null }, // Only top-level discussions
include: {
sender: {
select: { name: true, email: true },
},
division: {
select: { name: true },
},
},
orderBy: { createdAt: "desc" },
take: 10,
});
// Format for frontend
const formattedDiscussions = discussions.map((d) => ({
id: d.id,
message: d.message,
sender: d.sender.name || d.sender.email,
date: d.createdAt.toISOString(),
division: d.division?.name || null,
isResolved: d.isResolved,
}));
return { data: formattedDiscussions };
} catch (error) {
logger.error({ error }, "Failed to fetch discussions");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
message: t.String(),
sender: t.String(),
date: t.String(),
division: t.Nullable(t.String()),
isResolved: t.Boolean(),
}),
),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get recent discussions" },
}, },
); );

View File

@@ -1,34 +1,51 @@
import { Card, Group, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { Card, Group, Loader, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { MessageCircle } from "lucide-react"; import { MessageCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/utils/api-client";
import { format } from "date-fns";
import { id } from "date-fns/locale";
interface DiscussionItem { interface DiscussionItem {
id: string;
message: string; message: string;
sender: string; sender: string;
date: string; date: string;
division: string | null;
isResolved: boolean;
} }
const discussions: DiscussionItem[] = [
{
message: "Kepada Pelayanan, mohon di cek...",
sender: "I.B Surya Prabhawa Manu",
date: "12 Apr 2025",
},
{
message: "Kepada staf perencanaan @suar...",
sender: "Ni Nyoman Yuliani",
date: "14 Jun 2025",
},
{
message: "ijin atau mohon kepada KBD sar...",
sender: "Ni Wayan Martini",
date: "12 Apr 2025",
},
];
export function DiscussionPanel() { export function DiscussionPanel() {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark"; const dark = colorScheme === "dark";
const [discussions, setDiscussions] = useState<DiscussionItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDiscussions() {
try {
const res = await apiClient.GET("/api/division/discussions");
if (res.data?.data) {
setDiscussions(res.data.data);
}
} catch (error) {
console.error("Failed to fetch discussions", error);
} finally {
setLoading(false);
}
}
fetchDiscussions();
}, []);
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), "dd MMM yyyy", { locale: id });
} catch {
return dateString;
}
};
return ( return (
<Card <Card
p="md" p="md"
@@ -50,36 +67,51 @@ export function DiscussionPanel() {
</Text> </Text>
</Group> </Group>
<Stack gap="sm"> <Stack gap="sm">
{discussions.map((discussion) => ( {loading ? (
<Card <Group justify="center" py="xl">
key={`${discussion.sender}-${discussion.date}`} <Loader />
p="sm" </Group>
radius="md" ) : discussions.length > 0 ? (
withBorder discussions.map((discussion) => (
bg={dark ? "#334155" : "#F1F5F9"} <Card
style={{ key={discussion.id}
borderColor: dark ? "#334155" : "#F1F5F9", p="sm"
}} radius="md"
> withBorder
<Text bg={dark ? "#334155" : "#F1F5F9"}
size="sm" style={{
c={dark ? "white" : "#1E3A5F"} borderColor: dark ? "#334155" : "#F1F5F9",
fw={500} }}
mb="xs"
lineClamp={2}
> >
{discussion.message} <Text
</Text> size="sm"
<Group justify="space-between"> c={dark ? "white" : "#1E3A5F"}
<Text size="xs" c="dimmed"> fw={500}
{discussion.sender} mb="xs"
lineClamp={2}
>
{discussion.message}
</Text> </Text>
<Text size="xs" c="dimmed"> <Group justify="space-between">
{discussion.date} <Text size="xs" c="dimmed">
</Text> {discussion.sender}
</Group> {discussion.division && (
</Card> <Text span size="xs" c="dimmed" ml="xs">
))} {discussion.division}
</Text>
)}
</Text>
<Text size="xs" c="dimmed">
{formatDate(discussion.date)}
</Text>
</Group>
</Card>
))
) : (
<Text size="sm" c="dimmed" ta="center" py="xl">
Tidak ada diskusi
</Text>
)}
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -1,4 +1,5 @@
import { Card, Text, useMantineColorScheme } from "@mantine/core"; import { Card, Group, Loader, Text, useMantineColorScheme } from "@mantine/core";
import { useEffect, useState } from "react";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -9,16 +10,38 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { apiClient } from "@/utils/api-client";
const documentData = [ interface DocumentData {
{ name: "Gambar", jumlah: 300, color: "#FACC15" }, name: string;
{ name: "Dokumen", jumlah: 310, color: "#22C55E" }, jumlah: number;
]; color: string;
}
export function DocumentChart() { export function DocumentChart() {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark"; const dark = colorScheme === "dark";
const [data, setData] = useState<DocumentData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchDocumentStats() {
try {
const res = await apiClient.GET("/api/division/documents/stats");
if (res.data?.data) {
setData(res.data.data);
}
} catch (error) {
console.error("Failed to fetch document stats", error);
} finally {
setLoading(false);
}
}
fetchDocumentStats();
}, []);
return ( return (
<Card <Card
p="md" p="md"
@@ -36,39 +59,52 @@ export function DocumentChart() {
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md"> <Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
Jumlah Dokumen Jumlah Dokumen
</Text> </Text>
<ResponsiveContainer width="100%" height={200}> {loading ? (
<BarChart data={documentData}> <Group justify="center" py="xl">
<CartesianGrid <Loader />
strokeDasharray="3 3" </Group>
vertical={false} ) : data.length > 0 ? (
stroke={dark ? "#334155" : "#e5e7eb"} <ResponsiveContainer width="100%" height={200}>
/> <BarChart data={data}>
<XAxis <CartesianGrid
dataKey="name" strokeDasharray="3 3"
axisLine={false} vertical={false}
tickLine={false} stroke={dark ? "#334155" : "#e5e7eb"}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }} />
/> <XAxis
<YAxis dataKey="name"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }} tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
/> />
<Tooltip <YAxis
contentStyle={{ axisLine={false}
backgroundColor: dark ? "#1E293B" : "white", tickLine={false}
borderColor: dark ? "#334155" : "#e5e7eb", tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
borderRadius: "8px", allowDecimals={false}
}} />
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }} <Tooltip
/> contentStyle={{
<Bar dataKey="jumlah" radius={[4, 4, 0, 0]}> backgroundColor: dark ? "#1E293B" : "white",
{documentData.map((entry) => ( borderColor: dark ? "#334155" : "#e5e7eb",
<Cell key={`cell-${entry.name}`} fill={entry.color} /> borderRadius: "8px",
))} }}
</Bar> labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
</BarChart> />
</ResponsiveContainer> <Bar dataKey="jumlah" radius={[4, 4, 0, 0]}>
{data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" py="xl">
<Text size="sm" c="dimmed">
Tidak ada dokumen
</Text>
</Group>
)}
</Card> </Card>
); );
} }

View File

@@ -2,23 +2,68 @@ import {
Box, Box,
Card, Card,
Group, Group,
Loader,
Stack, Stack,
Text, Text,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useState } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { apiClient } from "@/utils/api-client";
const progressData = [ interface ProgressData {
{ name: "Selesai", value: 83.33, color: "#22C55E" }, name: string;
{ name: "Dikerjakan", value: 16.67, color: "#F59E0B" }, value: number;
{ name: "Segera Dikerjakan", value: 0, color: "#3B82F6" }, color: string;
{ name: "Dibatalkan", value: 0, color: "#EF4444" }, }
];
interface ActivityStats {
total: number;
counts: {
selesai: number;
berjalan: number;
tertunda: number;
dibatalkan: number;
};
percentages: {
selesai: number;
berjalan: number;
tertunda: number;
dibatalkan: number;
};
}
export function ProgressChart() { export function ProgressChart() {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark"; const dark = colorScheme === "dark";
const [data, setData] = useState<ProgressData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchActivityStats() {
try {
const res = await apiClient.GET("/api/division/activities/stats");
if (res.data?.data) {
const stats = res.data.data as ActivityStats;
const chartData: ProgressData[] = [
{ name: "Selesai", value: stats.percentages.selesai, color: "#22C55E" },
{ name: "Dikerjakan", value: stats.percentages.berjalan, color: "#F59E0B" },
{ name: "Segera Dikerjakan", value: stats.percentages.tertunda, color: "#3B82F6" },
{ name: "Dibatalkan", value: stats.percentages.dibatalkan, color: "#EF4444" },
];
setData(chartData);
}
} catch (error) {
console.error("Failed to fetch activity stats", error);
} finally {
setLoading(false);
}
}
fetchActivityStats();
}, []);
return ( return (
<Card <Card
p="md" p="md"
@@ -36,49 +81,57 @@ export function ProgressChart() {
<Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md"> <Text size="sm" fw={600} c={dark ? "white" : "#1E3A5F"} mb="md">
Progres Kegiatan Progres Kegiatan
</Text> </Text>
<ResponsiveContainer width="100%" height={200}> {loading ? (
<PieChart> <Group justify="center" py="xl">
<Pie <Loader />
data={progressData} </Group>
cx="50%" ) : (
cy="50%" <>
innerRadius={60} <ResponsiveContainer width="100%" height={200}>
outerRadius={80} <PieChart>
paddingAngle={2} <Pie
dataKey="value" data={data}
> cx="50%"
{progressData.map((entry) => ( cy="50%"
<Cell key={`cell-${entry.name}`} fill={entry.color} /> innerRadius={60}
))} outerRadius={80}
</Pie> paddingAngle={2}
<Tooltip dataKey="value"
contentStyle={{ >
backgroundColor: dark ? "#1E293B" : "white", {data.map((entry) => (
borderColor: dark ? "#334155" : "#e5e7eb", <Cell key={`cell-${entry.name}`} fill={entry.color} />
borderRadius: "8px", ))}
}} </Pie>
/> <Tooltip
</PieChart> contentStyle={{
</ResponsiveContainer> backgroundColor: dark ? "#1E293B" : "white",
<Stack gap="xs" mt="md"> borderColor: dark ? "#334155" : "#e5e7eb",
{progressData.map((item) => ( borderRadius: "8px",
<Group key={item.name} justify="space-between"> }}
<Group gap="xs">
<Box
w={12}
h={12}
style={{ backgroundColor: item.color, borderRadius: 2 }}
/> />
<Text size="sm" c={dark ? "white" : "gray.7"}> </PieChart>
{item.name} </ResponsiveContainer>
</Text> <Stack gap="xs" mt="md">
</Group> {data.map((item) => (
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}> <Group key={item.name} justify="space-between">
{item.value.toFixed(2)}% <Group gap="xs">
</Text> <Box
</Group> w={12}
))} h={12}
</Stack> style={{ backgroundColor: item.color, borderRadius: 2 }}
/>
<Text size="sm" c={dark ? "white" : "gray.7"}>
{item.name}
</Text>
</Group>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{item.value.toFixed(2)}%
</Text>
</Group>
))}
</Stack>
</>
)}
</Card> </Card>
); );
} }