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;
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": {
parameters: {
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": {
parameters: {
query?: never;

View File

@@ -2561,6 +2561,157 @@
"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": {
"get": {
"responses": {

View File

@@ -62,6 +62,46 @@ export const complaint = new Elysia({
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(
"/service-stats",
async ({ set }) => {

View File

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