feat(database): implement resident and complaint API and connect DemografiPekerjaan

This commit is contained in:
2026-03-26 14:17:41 +08:00
parent aeedb17402
commit 0900b8f199
7 changed files with 486 additions and 334 deletions

View File

@@ -222,6 +222,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/complaint/innovation-ideas": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get recent innovation ideas */
get: operations["getApiComplaintInnovation-ideas"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/resident/stats": { "/api/resident/stats": {
parameters: { parameters: {
query?: never; query?: never;
@@ -263,7 +280,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get religious and gender demographics */ /** Get demographics including religion, gender, occupation and age */
get: operations["getApiResidentDemographics"]; get: operations["getApiResidentDemographics"];
put?: never; put?: never;
post?: never; post?: never;
@@ -932,6 +949,23 @@ export interface operations {
}; };
}; };
}; };
"getApiComplaintInnovation-ideas": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getApiResidentStats: { getApiResidentStats: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -1468,6 +1468,15 @@
} }
} }
}, },
"/api/complaint/innovation-ideas": {
"get": {
"operationId": "getApiComplaintInnovation-ideas",
"summary": "Get recent innovation ideas",
"responses": {
"200": {}
}
}
},
"/api/resident/stats": { "/api/resident/stats": {
"get": { "get": {
"operationId": "getApiResidentStats", "operationId": "getApiResidentStats",
@@ -1489,7 +1498,7 @@
"/api/resident/demographics": { "/api/resident/demographics": {
"get": { "get": {
"operationId": "getApiResidentDemographics", "operationId": "getApiResidentDemographics",
"summary": "Get religious and gender demographics", "summary": "Get demographics including religion, gender, occupation and age",
"responses": { "responses": {
"200": {} "200": {}
} }

View File

@@ -63,4 +63,23 @@ export const complaint = new Elysia({
{ {
detail: { summary: "Get service letter statistics by type" }, detail: { summary: "Get service letter statistics by type" },
}, },
)
.get(
"/innovation-ideas",
async ({ set }) => {
try {
const ideas = await prisma.innovationIdea.findMany({
orderBy: { createdAt: "desc" },
take: 5,
});
return { data: ideas };
} catch (error) {
logger.error({ error }, "Failed to fetch innovation ideas");
set.status = 500;
return { error: "Internal Server Error" };
}
},
{
detail: { summary: "Get recent innovation ideas" },
},
); );

View File

@@ -53,7 +53,7 @@ export const resident = new Elysia({
"/demographics", "/demographics",
async ({ set }) => { async ({ set }) => {
try { try {
const [religion, gender] = await Promise.all([ const [religion, gender, occupation, ageGroups] = await Promise.all([
prisma.resident.groupBy({ prisma.resident.groupBy({
by: ["religion"], by: ["religion"],
_count: { _all: true }, _count: { _all: true },
@@ -62,8 +62,31 @@ export const resident = new Elysia({
by: ["gender"], by: ["gender"],
_count: { _all: true }, _count: { _all: true },
}), }),
prisma.resident.groupBy({
by: ["occupation"],
_count: { _all: true },
orderBy: { _count: { occupation: "desc" } },
take: 10,
}),
// Group by age ranges (simplified calculation)
prisma.$queryRaw<any[]>`
SELECT
CASE
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 0 AND 16 THEN '0-16'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 17 AND 25 THEN '17-25'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 26 AND 35 THEN '26-35'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 36 AND 45 THEN '36-45'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 46 AND 55 THEN '46-55'
WHEN date_part('year', age(now(), "birthDate")) BETWEEN 56 AND 65 THEN '56-65'
ELSE '65+'
END as range,
COUNT(*) as count
FROM resident
GROUP BY range
ORDER BY range ASC
`,
]); ]);
return { data: { religion, gender } }; return { data: { religion, gender, occupation, ageGroups } };
} catch (error) { } catch (error) {
logger.error({ error }, "Failed to fetch demographics"); logger.error({ error }, "Failed to fetch demographics");
set.status = 500; set.status = 500;
@@ -71,6 +94,9 @@ export const resident = new Elysia({
} }
}, },
{ {
detail: { summary: "Get religious and gender demographics" }, detail: {
summary:
"Get demographics including religion, gender, occupation and age",
},
}, },
); );

View File

@@ -55,7 +55,11 @@ export function DashboardContent() {
proses: 0, proses: 0,
selesai: 0, selesai: 0,
}, },
residents: (residentRes.data as any)?.data || { total: 0, heads: 0, poor: 0 }, residents: (residentRes.data as any)?.data || {
total: 0,
heads: 0,
poor: 0,
},
loading: false, loading: false,
}); });
} catch (error) { } catch (error) {

View File

@@ -5,6 +5,7 @@ import {
Grid, Grid,
GridCol, GridCol,
Group, Group,
Loader,
Progress, Progress,
Stack, Stack,
Text, Text,
@@ -21,6 +22,7 @@ import {
TrendingDown, TrendingDown,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -33,106 +35,9 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { apiClient } from "@/utils/api-client";
// KPI Data // Sektor Unggulan Data (Mock for now)
const kpiData = [
{
id: 1,
title: "Total Penduduk",
value: "5.634",
subtitle: "Aktif terdaftar",
icon: Users,
},
{
id: 2,
title: "Kepala Keluarga",
value: "1.354",
subtitle: "Total KK",
icon: Home,
},
{
id: 3,
title: "Kelahiran",
value: "23",
subtitle: "Tahun ini",
icon: Baby,
},
{
id: 4,
title: "Kemiskinan",
value: "324",
subtitle: "-10% dari tahun lalu",
trend: "positive",
icon: TrendingDown,
},
];
// Age Distribution Data
const ageDistributionData = [
{ ageRange: "17-25", total: 850 },
{ ageRange: "26-35", total: 1200 },
{ ageRange: "36-45", total: 1100 },
{ ageRange: "46-55", total: 950 },
{ ageRange: "56-65", total: 750 },
{ ageRange: "65+", total: 484 },
];
// Job Distribution Data
const jobDistributionData = [
{ job: "Sipil", total: 1200 },
{ job: "Guru", total: 850 },
{ job: "Petani", total: 950 },
{ job: "Pedagang", total: 750 },
{ job: "Wiraswasta", total: 984 },
];
// Religion Data
const religionData = [
{ name: "Hindu", value: 4234, color: "#EF4444" },
{ name: "Islam", value: 856, color: "#3B82F6" },
{ name: "Kristen", value: 412, color: "#22C55E" },
{ name: "Buddha", value: 202, color: "#FACC15" },
];
// Banjar Data
const banjarData = [
{ banjar: "Darmasaba", population: 1200, kk: 300, poor: 45 },
{ banjar: "Manesa", population: 950, kk: 240, poor: 32 },
{ banjar: "Cabe", population: 800, kk: 200, poor: 28 },
{ banjar: "Penenjoan", population: 1100, kk: 280, poor: 38 },
{ banjar: "Baler Pasar", population: 984, kk: 250, poor: 42 },
{ banjar: "Bucu", population: 600, kk: 184, poor: 25 },
];
// Dynamic Stats Data
const dynamicStats = [
{
title: "Kelahiran",
value: "23",
icon: Baby,
color: "#22C55E",
},
{
title: "Kematian",
value: "12",
icon: TrendingDown,
color: "#EF4444",
},
{
title: "Pindah Masuk",
value: "45",
icon: Users,
color: "#3B82F6",
},
{
title: "Pindah Keluar",
value: "32",
icon: Users,
color: "#3B82F6",
},
];
// Sektor Unggulan Data
const sektorUnggulanData = [ const sektorUnggulanData = [
{ sektor: "Pertanian", value: 65 }, { sektor: "Pertanian", value: 65 },
{ sektor: "Perdagangan", value: 45 }, { sektor: "Perdagangan", value: 45 },
@@ -144,6 +49,134 @@ const DemografiPekerjaan = () => {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark"; const dark = colorScheme === "dark";
const [stats, setStats] = useState({
total: 0,
heads: 0,
poor: 0,
});
const [ageData, setAgeData] = useState<any[]>([]);
const [jobData, setJobData] = useState<any[]>([]);
const [religionData, setReligionData] = useState<any[]>([]);
const [banjarData, setBanjarData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [statsRes, banjarRes, demoRes] = await Promise.all([
apiClient.GET("/api/resident/stats"),
apiClient.GET("/api/resident/banjar-stats"),
apiClient.GET("/api/resident/demographics"),
]);
if (statsRes.data?.data) setStats(statsRes.data.data);
if (banjarRes.data?.data) setBanjarData(banjarRes.data.data);
if (demoRes.data?.data) {
const { religion, occupation, ageGroups } = demoRes.data.data;
const religionColors: Record<string, string> = {
HINDU: "#EF4444",
ISLAM: "#3B82F6",
KRISTEN: "#22C55E",
KATOLIK: "#A855F7",
BUDDHA: "#FACC15",
KONGHUCU: "#F97316",
LAINNYA: "#94A3B8",
};
setReligionData(
(religion as any[]).map((r) => ({
name: r.religion,
value: r._count._all,
color: religionColors[r.religion] || "#94A3B8",
})),
);
setJobData(
(occupation as any[]).map((o) => ({
job: o.occupation || "Lainnya",
total: o._count._all,
})),
);
setAgeData(
(ageGroups as any[]).map((a) => ({
ageRange: a.range,
total: Number(a.count),
})),
);
}
} catch (error) {
console.error("Failed to fetch demografi data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// KPI Data
const kpiData = [
{
id: 1,
title: "Total Penduduk",
value: stats.total.toLocaleString(),
subtitle: "Aktif terdaftar",
icon: Users,
},
{
id: 2,
title: "Kepala Keluarga",
value: stats.heads.toLocaleString(),
subtitle: "Total KK",
icon: Home,
},
{
id: 3,
title: "Kelahiran",
value: "0",
subtitle: "Tahun ini",
icon: Baby,
},
{
id: 4,
title: "Kemiskinan",
value: stats.poor.toLocaleString(),
subtitle: "Keluarga Prasejahtera",
trend: "positive",
icon: TrendingDown,
},
];
// Dynamic Stats Data (Mock for now as no records in DB yet)
const dynamicStats = [
{
title: "Kelahiran",
value: "0",
icon: Baby,
color: "#22C55E",
},
{
title: "Kematian",
value: "0",
icon: TrendingDown,
color: "#EF4444",
},
{
title: "Pindah Masuk",
value: "0",
icon: Users,
color: "#3B82F6",
},
{
title: "Pindah Keluar",
value: "0",
icon: Users,
color: "#3B82F6",
},
];
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{/* TOP SECTION - 4 STAT CARDS */} {/* TOP SECTION - 4 STAT CARDS */}
@@ -226,44 +259,50 @@ const DemografiPekerjaan = () => {
</Title> </Title>
</Group> </Group>
<ResponsiveContainer width="100%" height={250}> <ResponsiveContainer width="100%" height={250}>
<BarChart data={ageDistributionData}> {loading ? (
<CartesianGrid <Group justify="center" align="center" h="100%">
strokeDasharray="3 3" <Loader />
vertical={false} </Group>
stroke={dark ? "#334155" : "#e5e7eb"} ) : (
/> <BarChart data={ageData}>
<XAxis <CartesianGrid
dataKey="ageRange" strokeDasharray="3 3"
axisLine={false} vertical={false}
tickLine={false} stroke={dark ? "#334155" : "#e5e7eb"}
tick={{ />
fill: dark ? "#E2E8F0" : "#374151", <XAxis
fontSize: 12, dataKey="ageRange"
}} axisLine={false}
/> tickLine={false}
<YAxis tick={{
axisLine={false} fill: dark ? "#E2E8F0" : "#374151",
tickLine={false} fontSize: 12,
tick={{ }}
fill: dark ? "#E2E8F0" : "#374151", />
fontSize: 12, <YAxis
}} axisLine={false}
/> tickLine={false}
<Tooltip tick={{
contentStyle={{ fill: dark ? "#E2E8F0" : "#374151",
backgroundColor: dark ? "#1E293B" : "white", fontSize: 12,
borderColor: dark ? "#334155" : "#e5e7eb", }}
borderRadius: "8px", />
}} <Tooltip
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }} contentStyle={{
/> backgroundColor: dark ? "#1E293B" : "white",
<Bar borderColor: dark ? "#334155" : "#e5e7eb",
dataKey="total" borderRadius: "8px",
fill="#396aaaff" }}
radius={[8, 8, 0, 0]} labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
maxBarSize={40} />
/> <Bar
</BarChart> dataKey="total"
fill="#396aaaff"
radius={[8, 8, 0, 0]}
maxBarSize={40}
/>
</BarChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</Card> </Card>
</Grid.Col> </Grid.Col>
@@ -290,46 +329,52 @@ const DemografiPekerjaan = () => {
</Title> </Title>
</Group> </Group>
<ResponsiveContainer width="100%" height={250}> <ResponsiveContainer width="100%" height={250}>
<BarChart data={jobDistributionData} layout="vertical"> {loading ? (
<CartesianGrid <Group justify="center" align="center" h="100%">
strokeDasharray="3 3" <Loader />
horizontal={false} </Group>
stroke={dark ? "#334155" : "#e5e7eb"} ) : (
/> <BarChart data={jobData} layout="vertical">
<XAxis <CartesianGrid
type="number" strokeDasharray="3 3"
axisLine={false} horizontal={false}
tickLine={false} stroke={dark ? "#334155" : "#e5e7eb"}
tick={{ />
fill: dark ? "#E2E8F0" : "#374151", <XAxis
fontSize: 12, type="number"
}} axisLine={false}
/> tickLine={false}
<YAxis tick={{
type="category" fill: dark ? "#E2E8F0" : "#374151",
dataKey="job" fontSize: 12,
axisLine={false} }}
tickLine={false} />
tick={{ <YAxis
fill: dark ? "#E2E8F0" : "#374151", type="category"
fontSize: 12, dataKey="job"
}} axisLine={false}
width={90} tickLine={false}
/> tick={{
<Tooltip fill: dark ? "#E2E8F0" : "#374151",
contentStyle={{ fontSize: 12,
backgroundColor: dark ? "#1E293B" : "white", }}
borderColor: dark ? "#334155" : "#e5e7eb", width={90}
borderRadius: "8px", />
}} <Tooltip
/> contentStyle={{
<Bar backgroundColor: dark ? "#1E293B" : "white",
dataKey="total" borderColor: dark ? "#334155" : "#e5e7eb",
fill="#396aaaff" borderRadius: "8px",
radius={[0, 8, 8, 0]} }}
maxBarSize={30} />
/> <Bar
</BarChart> dataKey="total"
fill="#396aaaff"
radius={[0, 8, 8, 0]}
maxBarSize={30}
/>
</BarChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</Card> </Card>
</Grid.Col> </Grid.Col>
@@ -420,50 +465,57 @@ const DemografiPekerjaan = () => {
</Title> </Title>
</Group> </Group>
<ResponsiveContainer width="100%" height={250}> <ResponsiveContainer width="100%" height={250}>
<PieChart> {loading ? (
<Pie <Group justify="center" align="center" h="100%">
data={religionData} <Loader />
cx="50%" </Group>
cy="50%" ) : (
innerRadius={60} <PieChart>
outerRadius={90} <Pie
paddingAngle={2} data={religionData}
dataKey="value" cx="50%"
> cy="50%"
{religionData.map((entry, index) => ( innerRadius={60}
<Cell key={`cell-${index}`} fill={entry.color} /> outerRadius={90}
))} paddingAngle={2}
</Pie> dataKey="value"
<Tooltip >
contentStyle={{ {religionData.map((entry, index) => (
backgroundColor: dark ? "#1E293B" : "white", <Cell key={`cell-${index}`} fill={entry.color} />
borderColor: dark ? "#334155" : "#e5e7eb", ))}
borderRadius: "8px", </Pie>
}} <Tooltip
/> contentStyle={{
</PieChart> backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
</PieChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
<Stack gap="xs" mt="md"> <Stack gap="xs" mt="md">
{religionData.map((item, index) => ( {!loading &&
<Group key={index} justify="space-between"> religionData.map((item, index) => (
<Group gap="xs"> <Group key={index} justify="space-between">
<Box <Group gap="xs">
w={10} <Box
h={10} w={10}
style={{ h={10}
backgroundColor: item.color, style={{
borderRadius: 2, backgroundColor: item.color,
}} borderRadius: 2,
/> }}
<Text size="sm" c={dark ? "white" : "gray.7"}> />
{item.name} <Text size="sm" c={dark ? "white" : "gray.7"}>
{item.name}
</Text>
</Group>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{item.value.toLocaleString()}
</Text> </Text>
</Group> </Group>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}> ))}
{item.value.toLocaleString()}
</Text>
</Group>
))}
</Stack> </Stack>
</Card> </Card>
</Grid.Col> </Grid.Col>
@@ -490,118 +542,124 @@ const DemografiPekerjaan = () => {
</Title> </Title>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}> {loading ? (
<thead> <Group justify="center" py="xl">
<tr> <Loader />
<th </Group>
style={{ ) : (
textAlign: "left", <table style={{ width: "100%", borderCollapse: "collapse" }}>
padding: "8px", <thead>
fontSize: "12px", <tr>
fontWeight: 600, <th
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
Banjar
</th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
Penduduk
</th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
KK
</th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
Miskin
</th>
</tr>
</thead>
<tbody>
{banjarData.map((item, index) => (
<tr
key={index}
style={{
backgroundColor:
index % 2 === 0
? dark
? "#334155"
: "#F8FAFC"
: "transparent",
transition: "background-color 0.15s ease",
}}
>
<td
style={{ style={{
padding: "10px 8px", textAlign: "left",
fontSize: "13px", padding: "8px",
fontWeight: 500, fontSize: "12px",
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{item.banjar}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{item.population.toLocaleString()}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{item.kk.toLocaleString()}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: "#EF4444",
fontWeight: 600, fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}} }}
> >
{item.poor.toLocaleString()} Banjar
</td> </th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
Penduduk
</th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
KK
</th>
<th
style={{
textAlign: "right",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
Miskin
</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {banjarData.map((item, index) => (
<tr
key={item.id || index}
style={{
backgroundColor:
index % 2 === 0
? dark
? "#334155"
: "#F8FAFC"
: "transparent",
transition: "background-color 0.15s ease",
}}
>
<td
style={{
padding: "10px 8px",
fontSize: "13px",
fontWeight: 500,
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{item.name}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{(item.totalPopulation || 0).toLocaleString()}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: dark ? "#E2E8F0" : "#1E293B",
}}
>
{(item.totalKK || 0).toLocaleString()}
</td>
<td
style={{
padding: "10px 8px",
textAlign: "right",
fontSize: "13px",
color: "#EF4444",
fontWeight: 600,
}}
>
{(item.totalPoor || 0).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</Box> </Box>
</Card> </Card>
</Grid.Col> </Grid.Col>

View File

@@ -27,10 +27,12 @@ export function DivisionList() {
try { try {
const { data } = await apiClient.GET("/api/division/"); const { data } = await apiClient.GET("/api/division/");
if (data?.data) { if (data?.data) {
const mapped = data.data.map((div: { name: string; _count?: { activities: number } }) => ({ const mapped = data.data.map(
name: div.name, (div: { name: string; _count?: { activities: number } }) => ({
count: div._count?.activities || 0, name: div.name,
})); count: div._count?.activities || 0,
}),
);
setDivisions(mapped); setDivisions(mapped);
} }
} catch (error) { } catch (error) {