feat: connect pengaduan-layanan-publik to live database

New API Endpoint:
- GET /api/complaint/trends - Fetch complaint trends for last 7 months

Component Updates:
- Removed hardcoded trenData array (7 months mock data)
- Removed hardcoded ideInovatif array (2 mock ideas)
- Added API calls to /api/complaint/trends and /api/complaint/innovation-ideas
- Added loading states for trend chart and innovation ideas
- Added empty states for both sections
- Connected LineChart to real complaint data
- Connected Innovation Ideas list to real InnovationIdea model

Features Added:
- Real-time complaint trend visualization
- Real innovation ideas from database
- Proper TypeScript typing for API responses
- Loading skeletons during data fetch
- Empty state messages when no data

Files changed:
- src/api/complaint.ts: Added /trends endpoint
- src/components/pengaduan-layanan-publik.tsx: Connected to APIs
- generated/api.ts: Regenerated types

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-27 16:26:24 +08:00
parent 097f9f34cc
commit 0736df8523
4 changed files with 397 additions and 105 deletions

View File

@@ -239,6 +239,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/complaint/trends": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get complaint trends for last 7 months */
get: operations["getApiComplaintTrends"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/complaint/service-stats": { "/api/complaint/service-stats": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1374,6 +1391,61 @@ export interface operations {
}; };
}; };
}; };
getApiComplaintTrends: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
month: string;
month_num: number;
count: number;
}[];
};
"multipart/form-data": {
data: {
month: string;
month_num: number;
count: number;
}[];
};
"text/plain": {
data: {
month: string;
month_num: number;
count: number;
}[];
};
};
};
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
"multipart/form-data": {
error: string;
};
"text/plain": {
error: string;
};
};
};
};
};
"getApiComplaintService-stats": { "getApiComplaintService-stats": {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -2561,6 +2561,157 @@
"summary": "Get recent complaints" "summary": "Get recent complaints"
} }
}, },
"/api/complaint/trends": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"month",
"month_num",
"count"
],
"properties": {
"month": {
"type": "string"
},
"month_num": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"month",
"month_num",
"count"
],
"properties": {
"month": {
"type": "string"
},
"month_num": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"month",
"month_num",
"count"
],
"properties": {
"month": {
"type": "string"
},
"month_num": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
}
}
},
"500": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
}
}
}
}
},
"operationId": "getApiComplaintTrends",
"summary": "Get complaint trends for last 7 months"
}
},
"/api/complaint/service-stats": { "/api/complaint/service-stats": {
"get": { "get": {
"responses": { "responses": {

View File

@@ -62,6 +62,46 @@ export const complaint = new Elysia({
detail: { summary: "Get recent complaints" }, detail: { summary: "Get recent complaints" },
}, },
) )
.get(
"/trends",
async ({ set }) => {
try {
// Get last 7 months complaint trends
const trends = await prisma.$queryRaw<
{ month: string; month_num: number; count: number }[]
>`
SELECT
TO_CHAR("createdAt", 'Mon') as month,
EXTRACT(MONTH FROM "createdAt") as month_num,
COUNT(*)::INTEGER as count
FROM complaint
WHERE "createdAt" > NOW() - INTERVAL '7 months'
GROUP BY month, month_num
ORDER BY month_num ASC
`;
return { data: trends };
} catch (error) {
logger.error({ error }, "Failed to fetch complaint trends");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
response: {
200: t.Object({
data: t.Array(
t.Object({
month: t.String(),
month_num: t.Number(),
count: t.Number(),
}),
),
}),
500: t.Object({ error: t.String() }),
},
detail: { summary: "Get complaint trends for last 7 months" },
},
)
.get( .get(
"/service-stats", "/service-stats",
async ({ set }) => { async ({ set }) => {

View File

@@ -30,37 +30,18 @@ import { apiClient } from "@/utils/api-client";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
// Tren pengaduan data (Mock for now) interface TrendData {
const trenData = [ bulan: string;
{ bulan: "Apr", jumlah: 35 }, jumlah: number;
{ bulan: "Mei", jumlah: 48 }, }
{ bulan: "Jun", jumlah: 42 },
{ bulan: "Jul", jumlah: 55 },
{ bulan: "Agu", jumlah: 50 },
{ bulan: "Sep", jumlah: 58 },
{ bulan: "Okt", jumlah: 52 },
];
// Ide inovatif data (Mock for now) interface InnovationIdea {
const ideInovatif = [
{
nama: "Andi Prasetyo",
judul: "Penerapan Smart Village",
waktu: "3 hari yang lalu",
kategori: "Teknologi",
},
{
nama: "Rina Kusuma",
judul: "Program Ekowisata Desa",
waktu: "5 hari yang lalu",
kategori: "Ekonomi",
},
];
interface Complaint {
id: string; id: string;
title: string; title: string;
description: string;
category: string; category: string;
submitterName: string;
submitterContact?: string;
status: string; status: string;
createdAt: string; createdAt: string;
} }
@@ -77,6 +58,14 @@ interface ServiceApiResponse {
}; };
} }
interface Complaint {
id: string;
title: string;
category: string;
status: string;
createdAt: string;
}
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "baru": case "baru":
@@ -103,16 +92,21 @@ const PengaduanLayananPublik = () => {
}); });
const [recentComplaints, setRecentComplaints] = useState<Complaint[]>([]); const [recentComplaints, setRecentComplaints] = useState<Complaint[]>([]);
const [serviceStats, setServiceStats] = useState<ServiceStat[]>([]); const [serviceStats, setServiceStats] = useState<ServiceStat[]>([]);
const [trendData, setTrendData] = useState<TrendData[]>([]);
const [innovationIdeas, setInnovationIdeas] = useState<InnovationIdea[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
const [statsRes, recentRes, serviceRes] = await Promise.all([ const [statsRes, recentRes, serviceRes, trendsRes, ideasRes] =
apiClient.GET("/api/complaint/stats"), await Promise.all([
apiClient.GET("/api/complaint/recent"), apiClient.GET("/api/complaint/stats"),
apiClient.GET("/api/complaint/service-stats"), apiClient.GET("/api/complaint/recent"),
]); apiClient.GET("/api/complaint/service-stats"),
apiClient.GET("/api/complaint/trends"),
apiClient.GET("/api/complaint/innovation-ideas"),
]);
if (statsRes.data?.data) setStats(statsRes.data.data); if (statsRes.data?.data) setStats(statsRes.data.data);
if (recentRes.data?.data) if (recentRes.data?.data)
@@ -126,6 +120,18 @@ const PengaduanLayananPublik = () => {
})); }));
setServiceStats(mappedService); setServiceStats(mappedService);
} }
if (trendsRes.data?.data) {
const mappedTrends = (
trendsRes.data.data as { month: string; count: number }[]
).map((item) => ({
bulan: item.month,
jumlah: item.count,
}));
setTrendData(mappedTrends);
}
if (ideasRes.data?.data) {
setInnovationIdeas(ideasRes.data.data as InnovationIdea[]);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch complaint data", error); console.error("Failed to fetch complaint data", error);
} finally { } finally {
@@ -228,44 +234,57 @@ const PengaduanLayananPublik = () => {
</Title> </Title>
</Group> </Group>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<LineChart data={trenData}> {loading ? (
<CartesianGrid <Group justify="center" align="center" h="100%">
strokeDasharray="3 3" <Loader />
vertical={false} </Group>
stroke={dark ? "#334155" : "#e5e7eb"} ) : trendData.length > 0 ? (
/> <LineChart data={trendData}>
<XAxis <CartesianGrid
dataKey="bulan" strokeDasharray="3 3"
axisLine={false} vertical={false}
tickLine={false} stroke={dark ? "#334155" : "#e5e7eb"}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }} />
/> <XAxis
<YAxis dataKey="bulan"
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={{
<Line backgroundColor: dark ? "#1E293B" : "white",
type="monotone" borderColor: dark ? "#334155" : "#e5e7eb",
dataKey="jumlah" borderRadius: "8px",
stroke="#396aaaff" }}
strokeWidth={2} labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
dot={{ />
fill: "#1E3A5F", <Line
strokeWidth: 2, type="monotone"
r: 4, dataKey="jumlah"
}} stroke="#396aaaff"
activeDot={{ r: 6 }} strokeWidth={2}
/> dot={{
</LineChart> fill: "#1E3A5F",
strokeWidth: 2,
r: 4,
}}
activeDot={{ r: 6 }}
/>
</LineChart>
) : (
<Group justify="center" align="center" h="100%">
<Text size="sm" c="dimmed">
Tidak ada data pengaduan 7 bulan terakhir
</Text>
</Group>
)}
</ResponsiveContainer> </ResponsiveContainer>
</Card> </Card>
@@ -415,41 +434,51 @@ const PengaduanLayananPublik = () => {
Ajuan Ide Inovatif Ajuan Ide Inovatif
</Title> </Title>
<Stack gap="sm"> <Stack gap="sm">
{ideInovatif.map((item) => ( {loading ? (
<Card <Group justify="center" py="xl">
key={item.judul} <Loader />
p="sm" </Group>
radius="md" ) : innovationIdeas.length > 0 ? (
withBorder innovationIdeas.map((item) => (
bg={dark ? "#334155" : "#F1F5F9"} <Card
style={{ key={item.id}
borderColor: "transparent", p="sm"
transition: "background-color 0.15s ease", radius="md"
}} withBorder
> bg={dark ? "#334155" : "#F1F5F9"}
<Group justify="space-between"> style={{
<Stack gap={0}> borderColor: "transparent",
<Text fw={600} c={dark ? "white" : "gray.9"}> transition: "background-color 0.15s ease",
{item.judul} }}
</Text> >
<Text size="sm" c="dimmed"> <Group justify="space-between">
{item.nama} <Stack gap={0}>
</Text> <Text fw={600} c={dark ? "white" : "gray.9"}>
<Text size="xs" c="dimmed"> {item.title}
{item.waktu} </Text>
</Text> <Text size="sm" c="dimmed">
</Stack> {item.submitterName}
<Button </Text>
size="xs" <Text size="xs" c="dimmed">
variant="light" {dayjs(item.createdAt).fromNow()}
color="darmasaba-blue" </Text>
radius="md" </Stack>
> <Button
Detail size="xs"
</Button> variant="light"
</Group> color="darmasaba-blue"
</Card> radius="md"
))} >
Detail
</Button>
</Group>
</Card>
))
) : (
<Text c="dimmed" ta="center">
Tidak ada ide inovatif
</Text>
)}
</Stack> </Stack>
</Card> </Card>
</Grid.Col> </Grid.Col>