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;
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": {
parameters: {
query?: never;
@@ -263,7 +280,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get religious and gender demographics */
/** Get demographics including religion, gender, occupation and age */
get: operations["getApiResidentDemographics"];
put?: 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: {
parameters: {
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": {
"get": {
"operationId": "getApiResidentStats",
@@ -1489,7 +1498,7 @@
"/api/resident/demographics": {
"get": {
"operationId": "getApiResidentDemographics",
"summary": "Get religious and gender demographics",
"summary": "Get demographics including religion, gender, occupation and age",
"responses": {
"200": {}
}

View File

@@ -63,4 +63,23 @@ export const complaint = new Elysia({
{
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",
async ({ set }) => {
try {
const [religion, gender] = await Promise.all([
const [religion, gender, occupation, ageGroups] = await Promise.all([
prisma.resident.groupBy({
by: ["religion"],
_count: { _all: true },
@@ -62,8 +62,31 @@ export const resident = new Elysia({
by: ["gender"],
_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) {
logger.error({ error }, "Failed to fetch demographics");
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,
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,
});
} catch (error) {

View File

@@ -5,6 +5,7 @@ import {
Grid,
GridCol,
Group,
Loader,
Progress,
Stack,
Text,
@@ -21,6 +22,7 @@ import {
TrendingDown,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
@@ -33,106 +35,9 @@ import {
XAxis,
YAxis,
} from "recharts";
import { apiClient } from "@/utils/api-client";
// KPI Data
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
// Sektor Unggulan Data (Mock for now)
const sektorUnggulanData = [
{ sektor: "Pertanian", value: 65 },
{ sektor: "Perdagangan", value: 45 },
@@ -144,6 +49,134 @@ const DemografiPekerjaan = () => {
const { colorScheme } = useMantineColorScheme();
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 (
<Stack gap="lg">
{/* TOP SECTION - 4 STAT CARDS */}
@@ -226,44 +259,50 @@ const DemografiPekerjaan = () => {
</Title>
</Group>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={ageDistributionData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="ageRange"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar
dataKey="total"
fill="#396aaaff"
radius={[8, 8, 0, 0]}
maxBarSize={40}
/>
</BarChart>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<BarChart data={ageData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="ageRange"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar
dataKey="total"
fill="#396aaaff"
radius={[8, 8, 0, 0]}
maxBarSize={40}
/>
</BarChart>
)}
</ResponsiveContainer>
</Card>
</Grid.Col>
@@ -290,46 +329,52 @@ const DemografiPekerjaan = () => {
</Title>
</Group>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={jobDistributionData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<YAxis
type="category"
dataKey="job"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
<Bar
dataKey="total"
fill="#396aaaff"
radius={[0, 8, 8, 0]}
maxBarSize={30}
/>
</BarChart>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<BarChart data={jobData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
/>
<YAxis
type="category"
dataKey="job"
axisLine={false}
tickLine={false}
tick={{
fill: dark ? "#E2E8F0" : "#374151",
fontSize: 12,
}}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
<Bar
dataKey="total"
fill="#396aaaff"
radius={[0, 8, 8, 0]}
maxBarSize={30}
/>
</BarChart>
)}
</ResponsiveContainer>
</Card>
</Grid.Col>
@@ -420,50 +465,57 @@ const DemografiPekerjaan = () => {
</Title>
</Group>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={religionData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{religionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
</PieChart>
{loading ? (
<Group justify="center" align="center" h="100%">
<Loader />
</Group>
) : (
<PieChart>
<Pie
data={religionData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{religionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: dark ? "#1E293B" : "white",
borderColor: dark ? "#334155" : "#e5e7eb",
borderRadius: "8px",
}}
/>
</PieChart>
)}
</ResponsiveContainer>
<Stack gap="xs" mt="md">
{religionData.map((item, index) => (
<Group key={index} justify="space-between">
<Group gap="xs">
<Box
w={10}
h={10}
style={{
backgroundColor: item.color,
borderRadius: 2,
}}
/>
<Text size="sm" c={dark ? "white" : "gray.7"}>
{item.name}
{!loading &&
religionData.map((item, index) => (
<Group key={index} justify="space-between">
<Group gap="xs">
<Box
w={10}
h={10}
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.toLocaleString()}
</Text>
</Group>
<Text size="sm" fw={600} c={dark ? "white" : "gray.9"}>
{item.value.toLocaleString()}
</Text>
</Group>
))}
))}
</Stack>
</Card>
</Grid.Col>
@@ -490,118 +542,124 @@ const DemografiPekerjaan = () => {
</Title>
</Group>
<Box style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th
style={{
textAlign: "left",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
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
{loading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th
style={{
padding: "10px 8px",
fontSize: "13px",
fontWeight: 500,
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",
textAlign: "left",
padding: "8px",
fontSize: "12px",
fontWeight: 600,
color: dark ? "#94A3B8" : "#64748B",
borderBottom: `1px solid ${dark ? "#334155" : "#e5e7eb"}`,
}}
>
{item.poor.toLocaleString()}
</td>
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>
))}
</tbody>
</table>
</thead>
<tbody>
{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>
</Card>
</Grid.Col>

View File

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