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:
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user