feat: initial commit with seeded database and premium ui changes

This commit is contained in:
Dodo
2026-06-11 18:41:42 +07:00
commit edf94ae5c1
1684 changed files with 33073 additions and 0 deletions
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const LABELS = [
"SANGAT TINGGI",
"TINGGI",
"SEDANG",
"RENDAH",
"TIDAK PRIORITAS",
] as const;
const LABEL_STYLES: Record<string, { bar: string; text: string; dot: string }> = {
"SANGAT TINGGI": { bar: "bg-red-500", text: "text-red-700", dot: "bg-red-500" },
"TINGGI": { bar: "bg-orange-500", text: "text-orange-700", dot: "bg-orange-500" },
"SEDANG": { bar: "bg-amber-500", text: "text-amber-700", dot: "bg-amber-500" },
"RENDAH": { bar: "bg-emerald-500", text: "text-emerald-700", dot: "bg-emerald-500" },
"TIDAK PRIORITAS": { bar: "bg-blue-500", text: "text-blue-700", dot: "bg-blue-500" },
};
export function FuzzyPriorityStats({
refreshKey,
onRecomputed,
}: {
refreshKey?: number;
onRecomputed?: () => void;
}) {
const [stats, setStats] = useState<Record<string, number> | null>(null);
const [recomputing, setRecomputing] = useState(false);
const [recomputeMsg, setRecomputeMsg] = useState<string | null>(null);
useEffect(() => {
fetch(`${BACKEND_URL}/households/stats-fuzzy`)
.then((r) => r.json())
.then(setStats)
.catch(() => setStats(null));
}, [refreshKey]);
async function handleRecompute() {
setRecomputing(true);
setRecomputeMsg(null);
try {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households/recompute-fuzzy`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menghitung ulang");
}
const data = await res.json();
setRecomputeMsg(`${data.updated} dari ${data.total} warga dihitung ulang`);
onRecomputed?.();
} catch (err) {
setRecomputeMsg(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setRecomputing(false);
}
}
if (!stats) return null;
const total = LABELS.reduce((sum, l) => sum + (stats[l] ?? 0), 0);
if (total === 0) return null;
return (
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
<DashboardCard className="gap-0 h-full py-0">
<CardHeader className="border-b pt-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Distribusi Prioritas Fuzzy</CardTitle>
<CardDescription>
{recomputeMsg ?? `${total} warga teranalisis oleh sistem fuzzy`}
</CardDescription>
</div>
<button
type="button"
onClick={handleRecompute}
disabled={recomputing}
className="h-8 shrink-0 rounded-full border border-border px-4 text-xs font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
{recomputing ? "Menghitung…" : "Hitung Ulang Semua"}
</button>
</CardHeader>
<CardContent className="px-0 py-0 flex-1 flex flex-col">
<div className="grid grid-cols-5 divide-x flex-1">
{LABELS.map((label) => {
const count = stats[label] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const style = LABEL_STYLES[label];
return (
<div key={label} className="flex flex-col items-center gap-2 px-4 py-5">
<div className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full shrink-0 ${style.dot}`} />
<span className={`text-[11px] font-medium leading-tight text-center ${style.text}`}>
{label}
</span>
</div>
<span className="text-2xl font-bold tabular-nums">{count}</span>
<div className="w-full h-1.5 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full transition-all ${style.bar}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{pct}%</span>
</div>
);
})}
</div>
</CardContent>
</DashboardCard>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { UserMarker } from "@/components/leaflet-map";
export const FUZZY_BADGE: Record<string, { bg: string; text: string; bar: string }> = {
"SANGAT TINGGI": { bg: "#fef2f2", text: "#b91c1c", bar: "#ef4444" },
"TINGGI": { bg: "#fff7ed", text: "#c2410c", bar: "#f97316" },
"SEDANG": { bg: "#fffbeb", text: "#b45309", bar: "#f59e0b" },
"RENDAH": { bg: "#f0fdf4", text: "#15803d", bar: "#10b981" },
"TIDAK PRIORITAS": { bg: "#eff6ff", text: "#1d4ed8", bar: "#6b7280" },
};
const FACTOR_LABELS: Record<string, string> = {
penghasilan: "Penghasilan",
tanggungan: "Tanggungan",
kondisi_rumah: "Kondisi Rumah",
kepemilikan_aset: "Kepemilikan Aset",
};
interface FuzzyResultStepProps {
marker: UserMarker;
confirmLabel: string;
onConfirm: () => void;
}
export function FuzzyResultStep({ marker, confirmLabel, onConfirm }: FuzzyResultStepProps) {
const label = marker.meta?.fuzzy_label ?? "";
const score = marker.meta?.fuzzy_score ?? null;
const detail = marker.meta?.fuzzy_detail ?? null;
const badge = FUZZY_BADGE[label] ?? { bg: "#f3f4f6", text: "#374151", bar: "#6b7280" };
return (
<div className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Hasil Analisis Fuzzy
</DialogTitle>
<p className="text-center text-[11px] text-muted-foreground mt-0.5">
{marker.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-6">
{/* Priority badge */}
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Tingkat Prioritas
</span>
<span
className="px-5 py-1.5 rounded-full text-sm font-bold"
style={{ backgroundColor: badge.bg, color: badge.text }}
>
{label || "—"}
</span>
</div>
{/* Score bar */}
{score != null && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">Skor Fuzzy</span>
<span className="text-sm font-bold tabular-nums">{score.toFixed(1)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${score}%`, backgroundColor: badge.bar }}
/>
</div>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>0</span>
<span>100</span>
</div>
</div>
)}
{/* Factor breakdown — dominant fuzzy category per input */}
{detail && (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Faktor Penilaian
</span>
<div className="rounded-xl border border-border divide-y divide-border overflow-hidden">
{Object.entries(FACTOR_LABELS).map(([key, name]) => {
const f = detail[key];
if (!f) return null;
return (
<div key={key} className="flex items-center justify-between px-4 py-2 text-[11px]">
<span className="text-muted-foreground">{name}</span>
<span className="flex items-center gap-2">
<span className="font-medium">{f.kategori}</span>
<span className="w-12 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full"
style={{ width: `${Math.round(f.derajat * 100)}%`, backgroundColor: badge.bar }}
/>
</span>
</span>
</div>
);
})}
</div>
</div>
)}
{/* Summary grid */}
<div className="rounded-xl bg-muted/50 border border-border px-4 py-3 grid grid-cols-2 gap-y-2 gap-x-6 text-[11px]">
<span className="text-muted-foreground">Kemiskinan</span>
<span className="font-medium text-right">{marker.meta?.poverty_level ?? "—"}</span>
<span className="text-muted-foreground">Tanggungan</span>
<span className="font-medium text-right">{marker.meta?.family_count ?? "—"} jiwa</span>
<span className="text-muted-foreground">Penghasilan</span>
<span className="font-medium text-right">
Rp {(marker.meta?.penghasilan ?? 0).toLocaleString("id-ID")}/bln
</span>
<span className="text-muted-foreground">Koordinat</span>
<span className="font-mono text-right text-[10px]">
{marker.lat.toFixed(5)}, {marker.lng.toFixed(5)}
</span>
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t">
<button
type="button"
onClick={onConfirm}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90"
>
{confirmLabel}
</button>
</div>
</div>
);
}
@@ -0,0 +1,473 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
cathedral: "Gereja Katolik",
temple: "Pura",
vihara: "Vihara",
klenteng: "Klenteng",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function AdaptiveSlider({ value, onChange, hint }: { value: number; onChange: (v: number) => void; hint: string }) {
const pct = ((value - 1) / 9) * 100;
const color = value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range" min={1} max={10} value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)` }}
className="flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border"
/>
<span className="w-6 shrink-0 text-center text-xs font-bold tabular-nums" style={{ color }}>{value}</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
interface MarkerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
marker: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function MarkerEditDialog({ open, onOpenChange, marker, onUpdated, onDeleted }: MarkerEditDialogProps) {
const isHousehold = marker?.id.startsWith("hh-");
const isPOI = marker?.id.startsWith("poi-");
const isReligion = isPOI && RELIGION_OPTIONS.some((o) => o.value === marker?.type);
// Household fields
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
// POI fields
const [placeName, setPlaceName] = useState("");
const [religionType, setReligionType] = useState<string | null>(null);
const [radiusMeters, setRadiusMeters] = useState(0);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
const DELETE_SECONDS = 5;
const TOAST_ID = "marker-delete";
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Post-save fuzzy result step (household only)
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
// Delete countdown — lives here so it survives dialog close
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
// Holds the marker being deleted so handleDelete works after dialog closes
const pendingDeleteMarker = useRef<UserMarker | null>(null);
// Reset only when a DIFFERENT non-null marker is opened — skip on dialog close (marker → null)
useEffect(() => {
if (!marker?.id) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
setStep("form");
setSavedMarker(null);
}, [marker?.id]);
// Tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Show / update toast while countdown is running
useEffect(() => {
if (!deleteActive || !pendingDeleteMarker.current) {
toast.dismiss(TOAST_ID);
return;
}
const name = pendingDeleteMarker.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus marker</p>
<p className="text-xs text-muted-foreground truncate">"{name}" dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteMarker.current = marker;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
// Load existing data when dialog opens
useEffect(() => {
if (!open || !marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {} as Record<string, string>;
setIsLoading(true);
setError(null);
if (isHousehold) {
fetch(`${BACKEND_URL}/households`, { headers })
.then((r) => r.json())
.then((rows: any[]) => {
const row = rows.find((r: any) => String(r.id) === dbId);
if (row) {
setHeadName(row.head_name ?? "");
setFamilyCount(row.family_count ?? 3);
setPenghasilan(row.penghasilan ?? 1500000);
setKondisiRumah(row.kondisi_rumah ?? 5);
setKepemilikanAset(row.kepemilikan_aset ?? 5);
setNotes(row.notes ?? "");
}
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else if (isPOI) {
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
.then((r) => r.json())
.then((row: any) => {
setPlaceName(row.name ?? "");
setReligionType(row.religion_subtype ?? null);
setRadiusMeters(row.radius_meters ?? 0);
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, [open, marker]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const res = await fetch(`${BACKEND_URL}/households/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify({ head_name: headName, family_count: familyCount, notes: notes || null, penghasilan, kondisi_rumah: kondisiRumah, kepemilikan_aset: kepemilikanAset }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
const saved = await res.json();
setSavedMarker({
...marker,
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
});
setStep("result");
setIsSubmitting(false);
return;
} else {
const name = placeName.trim() || (marker.name);
const body: any = { name };
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; body.radius_meters = radiusMeters; }
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
onUpdated({
...marker,
name,
...(isReligion && religionType ? { type: religionType } : {}),
...(isReligion ? { meta: { ...marker.meta, radius: radiusMeters } } : {}),
});
}
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
async function handleDelete() {
const target = pendingDeleteMarker.current;
if (!target) return;
const dbId = target.id.split("-").slice(1).join("-");
const isTargetHousehold = target.id.startsWith("hh-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsDeleting(true);
setError(null);
try {
const url = isTargetHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
const res = await fetch(url, { method: "DELETE", headers });
if (!res.ok && res.status !== 204) throw new Error("Gagal menghapus");
pendingDeleteMarker.current = null;
onDeleted(target);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsDeleting(false);
}
}
const title = isHousehold
? `Edit ${MARKER_LABELS["marker"]}`
: `Edit ${MARKER_LABELS[marker?.type ?? ""] ?? marker?.type ?? "Marker"}`;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) { onOpenChange(false); } }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai"
onConfirm={() => {
onUpdated(savedMarker);
setStep("form");
setSavedMarker(null);
onOpenChange(false);
}}
/>
)}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{title}</DialogTitle>
{marker && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{marker.lat.toFixed(6)},&nbsp;{marker.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Memuat data</p>
) : isHousehold ? (
<>
<Field label="Nama Kepala Keluarga">
<Input value={headName} onChange={(e) => setHeadName(e.target.value)} required className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input type="number" min={1} max={20} value={familyCount} onChange={(e) => setFamilyCount(Number(e.target.value))} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah"><AdaptiveSlider value={kondisiRumah} onChange={setKondisiRumah} hint="1 = sangat buruk · 10 = sangat baik" /></Field>
<Field label="Kepemilikan Aset"><AdaptiveSlider value={kepemilikanAset} onChange={setKepemilikanAset} hint="1 = tidak ada · 10 = banyak" /></Field>
</div>
<Field label="Catatan">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none" />
</Field>
</>
) : (
<>
{isReligion && (
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
)}
<Field label="Nama Tempat">
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
{isReligion && (
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
)}
</>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Marker"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button type="button" onClick={() => onOpenChange(false)} disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50">
Batal
</button>
<button type="submit" disabled={isSubmitting || isLoading || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40">
{isSubmitting ? "Menyimpan…" : "Simpan"}
</button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,393 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { UserMarker } from "@/components/leaflet-map";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
interface PendingClick {
lat: number;
lng: number;
}
interface MarkerPlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
markerType: string;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
/* ─── Adaptive Slider ─────────────────────────────────────── */
function AdaptiveSlider({
value,
onChange,
hint,
}: {
value: number;
onChange: (v: number) => void;
hint: string;
}) {
const pct = ((value - 1) / 9) * 100;
const color =
value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={10}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-6 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color }}
>
{value}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
/* ─── Field wrapper ───────────────────────────────────────── */
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Main dialog ─────────────────────────────────────────── */
export function MarkerPlacementDialog({
open,
onOpenChange,
markerType,
pendingClick,
onConfirm,
}: MarkerPlacementDialogProps) {
const isHousehold = markerType === "marker";
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
const [placeName, setPlaceName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
function reset() {
setHeadName(""); setFamilyCount(3); setPenghasilan(1500000);
setKondisiRumah(5); setKepemilikanAset(5);
setNotes(""); setPlaceName(""); setError(null);
setStep("form"); setSavedMarker(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick) return;
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
head_name: headName,
family_count: familyCount,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
notes: notes || null,
penghasilan,
kondisi_rumah: kondisiRumah,
kepemilikan_aset: kepemilikanAset,
marker_type: "marker",
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
const marker: UserMarker = {
id: `hh-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: "marker",
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
};
setSavedMarker(marker);
setStep("result");
setIsSubmitting(false);
return;
} else {
const token = localStorage.getItem("auth_token");
const name = placeName.trim() || MARKER_LABELS[markerType] || markerType;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: markerType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: markerType,
name,
meta: { poi_type: markerType, notes: saved.notes ?? undefined },
});
}
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0"
>
{/* ── Fuzzy Result Step ── */}
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai & Pasang Marker"
onConfirm={() => {
onConfirm(savedMarker);
reset();
onOpenChange(false);
}}
/>
)}
{/* ── Form Step ── */}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
{/* ── Header ── */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
{isHousehold
? "Tambah Data Warga Miskin"
: `Tambah ${MARKER_LABELS[markerType] ?? markerType}`}
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
{/* ── Body ── */}
<div className="flex flex-col gap-5 px-6 py-5">
{isHousehold ? (
<>
{/* Row 1: Nama KK */}
<Field label="Nama Kepala Keluarga">
<Input
placeholder="cth. Budi Santoso"
value={headName}
onChange={(e) => setHeadName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Row 2: Penghasilan + Tanggungan */}
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input
type="number"
min={1}
max={20}
value={familyCount}
onChange={(e) => setFamilyCount(Number(e.target.value))}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
</div>
{/* Row 3: Sliders */}
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah">
<AdaptiveSlider
value={kondisiRumah}
onChange={setKondisiRumah}
hint="1 = sangat buruk · 10 = sangat baik"
/>
</Field>
<Field label="Kepemilikan Aset">
<AdaptiveSlider
value={kepemilikanAset}
onChange={setKepemilikanAset}
hint="1 = tidak ada · 10 = banyak"
/>
</Field>
</div>
{/* Row 4: Catatan */}
<Field label="Catatan">
<Textarea
placeholder="Informasi tambahan (opsional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none"
/>
</Field>
</>
) : (
<Field label="Nama Tempat">
<Input
placeholder={MARKER_LABELS[markerType] ?? "Nama lokasi"}
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</div>
{/* ── Footer ── */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,82 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/ui/phosphor-icons";
import type { HouseholdRow } from "@/lib/poverty-types";
const SCORE_COLOR: Record<string, string> = {
"SANGAT TINGGI": "text-red-600",
"TINGGI": "text-orange-600",
"SEDANG": "text-amber-600",
"RENDAH": "text-emerald-600",
"TIDAK PRIORITAS": "text-blue-600",
};
interface PovertyDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
households: HouseholdRow[];
selectedId: number | null;
onSelect: (household: HouseholdRow) => void;
}
export function PovertyDirectory({
searchQuery,
setSearchQuery,
households,
selectedId,
onSelect,
}: PovertyDirectoryProps) {
const filtered = households
.filter((h) => h.head_name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="py-4">
<CardTitle className="text-base font-semibold">Direktori Warga Prioritas</CardTitle>
<CardDescription>Diurutkan dari skor fuzzy tertinggi. Pilih warga untuk melihat detail di panel inspektur.</CardDescription>
</CardHeader>
<CardContent className="pb-6 flex-1">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari nama kepala keluarga..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{households.length === 0
? "Belum ada warga terdata. Tambah marker di peta untuk memulai."
: "Tidak ada warga yang cocok dengan pencarian."}
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 max-h-[320px] overflow-y-auto pr-1">
{filtered.map((h) => {
const isSelected = selectedId === h.id;
const score = h.fuzzy_score != null ? Number(h.fuzzy_score).toFixed(1) : "—";
return (
<div
key={h.id}
onClick={() => onSelect(h)}
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center transition-all ${
isSelected
? "border-primary bg-primary/5 text-primary font-semibold"
: "border-border/60 hover:bg-muted"
}`}
>
<span className="text-xs text-foreground font-medium truncate me-2">{h.head_name}</span>
<span className={`text-xs bg-muted border px-2 py-0.5 rounded font-bold tabular-nums shrink-0 ${SCORE_COLOR[h.fuzzy_label ?? ""] ?? "text-muted-foreground"}`}>
{score}
</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import { ArrowRightIcon, ArrowUpIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import type { HouseholdRow } from "@/lib/poverty-types";
const PREVIEW_COUNT = 5;
function PovertyBadge({ level }: { level: string }) {
if (level === "Extreme") {
return (
<Badge className="bg-red-100 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-800">
Ekstrem
</Badge>
);
}
if (level === "Miskin") {
return (
<Badge className="bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800">
Miskin
</Badge>
);
}
return (
<Badge className="bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-800">
Rentan
</Badge>
);
}
function FuzzyPriorityBadge({ label }: { label: string | null }) {
if (!label) return <span className="text-xs text-muted-foreground"></span>;
const colors: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
return (
<Badge className={colors[label] ?? "bg-muted text-muted-foreground"}>
{label}
</Badge>
);
}
function ScoreBar({ value }: { value: number }) {
const pct = Math.round((value / 10) * 100);
const color =
value <= 3 ? "bg-red-400" : value <= 6 ? "bg-amber-400" : "bg-emerald-400";
return (
<div className="flex items-center gap-1.5">
<div className="w-14 h-1.5 rounded-full bg-border overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{value}/10</span>
</div>
);
}
export function PovertyHouseholdsTable({
rows,
loading = false,
}: {
rows: HouseholdRow[];
loading?: boolean;
}) {
const [showAll, setShowAll] = useState(false);
const visible = showAll ? rows : rows.slice(0, PREVIEW_COUNT);
const hasMore = rows.length > PREVIEW_COUNT;
return (
<DashboardCard className="relative gap-0 h-full flex flex-col">
<CardHeader className="border-b">
<CardTitle className="text-base">Data Warga Miskin</CardTitle>
<CardDescription>
{loading ? "Memuat data…" : `${rows.length} entri tersimpan dari peta · skor prioritas fuzzy`}
</CardDescription>
</CardHeader>
<CardContent className={cn("px-0 flex-1", !showAll && hasMore && "mask-b-from-50% mask-b-to-100%")}>
{loading && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Memuat data...
</div>
)}
{!loading && rows.length === 0 && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Belum ada data. Tambah marker di peta untuk mencatat warga miskin.
</div>
)}
{!loading && rows.length > 0 && (
<Table>
<TableCaption className="sr-only">
Data warga miskin dengan skor prioritas fuzzy.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="ps-6 w-8">#</TableHead>
<TableHead>Kepala Keluarga</TableHead>
<TableHead className="text-center">Jiwa</TableHead>
<TableHead>Rumah</TableHead>
<TableHead>Aset</TableHead>
<TableHead>Tingkat</TableHead>
<TableHead className="text-right">Penghasilan</TableHead>
<TableHead className="text-right tabular-nums">Skor Fuzzy</TableHead>
<TableHead className="pe-6">Prioritas</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row, i) => (
<TableRow className="h-12" key={row.id}>
<TableCell className="ps-6 text-xs text-muted-foreground tabular-nums">
{i + 1}
</TableCell>
<TableCell className="font-medium max-w-[160px] truncate">
{row.head_name}
</TableCell>
<TableCell className="text-center text-xs tabular-nums text-muted-foreground">
{row.family_count}
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kondisi_rumah)} />
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kepemilikan_aset)} />
</TableCell>
<TableCell>
<PovertyBadge level={row.poverty_level} />
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
Rp {Number(row.penghasilan).toLocaleString("id-ID")}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{row.fuzzy_score != null ? Number(row.fuzzy_score).toFixed(1) : "—"}
</TableCell>
<TableCell className="pe-6">
<FuzzyPriorityBadge label={row.fuzzy_label} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* View All overlay — only when collapsed and there are hidden rows */}
{!showAll && hasMore && (
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
<Button variant="ghost" onClick={() => setShowAll(true)}>
Lihat Semua ({rows.length})
<ArrowRightIcon aria-hidden="true" />
</Button>
</div>
)}
{/* Collapse button — only when expanded */}
{showAll && rows.length > PREVIEW_COUNT && (
<div className="flex items-center justify-center border-t py-3">
<Button variant="ghost" onClick={() => setShowAll(false)}>
<ArrowUpIcon aria-hidden="true" />
Tutup
</Button>
</div>
)}
</DashboardCard>
);
}
@@ -0,0 +1,26 @@
const LEGEND = [
{ label: "Sangat Tinggi", color: "#ef4444" },
{ label: "Tinggi", color: "#f97316" },
{ label: "Sedang", color: "#f59e0b" },
{ label: "Rendah", color: "#10b981" },
{ label: "Tidak Prioritas", color: "#6b7280" },
];
export function PovertyHud() {
return (
<>
{/* Floating Legend — fuzzy priority marker colors */}
<div className="absolute bottom-6 left-4 z-[999] bg-background/90 backdrop-blur-md border border-border/40 p-3.5 rounded-lg shadow-lg text-xs flex flex-col gap-2">
<span className="font-bold text-foreground">Prioritas Fuzzy</span>
<div className="flex flex-col gap-1">
{LEGEND.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground text-[10px]">{item.label}</span>
</div>
))}
</div>
</div>
</>
);
}
@@ -0,0 +1,90 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { HouseholdRow } from "@/lib/poverty-types";
const LABEL_BADGE: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
interface PovertyInspectorTopProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorTop({ selected }: PovertyInspectorTopProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Inspektur Warga
</CardTitle>
<CardDescription>
{selected ? `Analisis untuk ${selected.head_name}.` : "Pilih warga dari direktori."}
</CardDescription>
</CardHeader>
<CardContent className="px-6 py-6 flex-1 flex flex-col justify-center">
{selected ? (
<>
<h3 className="text-2xl font-bold text-foreground truncate">{selected.head_name}</h3>
<span className={`inline-block mt-2 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border w-fit ${LABEL_BADGE[selected.fuzzy_label ?? ""] ?? "bg-muted text-muted-foreground border-border"}`}>
{selected.fuzzy_label ?? "Belum dianalisis"}
</span>
</>
) : (
<p className="text-sm text-muted-foreground">Belum ada warga dipilih.</p>
)}
</CardContent>
</Card>
);
}
interface PovertyInspectorBottomProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorBottom({ selected }: PovertyInspectorBottomProps) {
const score = selected?.fuzzy_score != null ? Number(selected.fuzzy_score).toFixed(1) : "—";
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardContent className="px-0 py-6 flex flex-col justify-between flex-1">
<div className="grid grid-cols-2 gap-4 px-6">
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Skor Fuzzy</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">{score}</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Penghasilan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `Rp ${Number(selected.penghasilan).toLocaleString("id-ID")}` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Tanggungan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.family_count} jiwa` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Rumah · Aset</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.kondisi_rumah} · ${selected.kepemilikan_aset}` : "—"}
</p>
</div>
</div>
<div className="border-t mt-6 pt-6 px-6 text-xs text-muted-foreground flex flex-col gap-2">
<p>
Skor fuzzy 0100 dihitung dari penghasilan, tanggungan, kondisi rumah, dan kepemilikan aset. Semakin tinggi skor, semakin prioritas untuk bantuan.
</p>
<p>
Kondisi rumah dan kepemilikan aset dinilai pada skala 110 saat pendataan di peta.
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
import { Card } from "@/components/ui/card";
import type { PovertyOverview } from "@/lib/poverty-types";
interface PovertyStatsCardsProps {
overview: PovertyOverview | null;
}
export function PovertyStatsCards({ overview }: PovertyStatsCardsProps) {
const totals = overview?.totals;
const byLabel = overview?.by_label ?? {};
const highPriority = (byLabel["SANGAT TINGGI"] ?? 0) + (byLabel["TINGGI"] ?? 0);
const poiTotal = Object.values(overview?.poi ?? {}).reduce((s, n) => s + n, 0);
const avgDependents =
totals && totals.household_count > 0
? (totals.total_dependents / totals.household_count).toFixed(1)
: null;
return (
<>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Warga Terdata</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.household_count : "—"} <span className="text-base font-semibold text-muted-foreground">KK</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{poiTotal} fasilitas umum terpetakan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Total Tanggungan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.total_dependents : "—"} <span className="text-base font-semibold text-muted-foreground">jiwa</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{avgDependents ? `rata-rata ${avgDependents} jiwa per KK` : "belum ada data"}
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Penghasilan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}` : "—"}
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
per kepala keluarga per bulan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Skor Fuzzy</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals?.avg_score != null ? Number(totals.avg_score).toFixed(1) : "—"}
<span className="text-base font-semibold text-muted-foreground"> / 100</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs mt-auto">
<span className="font-medium text-rose-500">{highPriority} KK</span>
<span className="text-muted-foreground"> prioritas tinggi ke atas</span>
</div>
</Card>
</>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import type { PovertyOverview } from "@/lib/poverty-types";
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
function formatMonth(ym: string) {
const [year, month] = ym.split("-");
return `${MONTH_NAMES[Number(month) - 1] ?? month} '${year.slice(2)}`;
}
interface PovertyTrendChartProps {
monthly: PovertyOverview["monthly"];
}
export function PovertyTrendChart({ monthly }: PovertyTrendChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const chartData = monthly.map((m) => ({
month: formatMonth(m.month),
pendataan: m.count,
skor: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
}));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">Tren Pendataan Warga</CardTitle>
<CardDescription>Jumlah pendataan baru dan rata-rata skor prioritas fuzzy per bulan, dari basis data.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 w-full pt-4 min-h-[320px] relative">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
Belum ada data pendataan. Tambah marker warga di peta untuk memulai.
</div>
) : !isMounted ? (
<div className="h-full w-full" />
) : (
<div className="absolute inset-0 pt-4 px-6 pb-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 15, left: -15, bottom: 0 }}>
<XAxis dataKey="month" tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<YAxis tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<Tooltip
contentStyle={{
background: "rgba(12, 10, 8, 0.95)",
border: "1px solid #4d505d",
borderRadius: "6px",
fontSize: "12px",
color: "#ffffff"
}}
/>
<Line
type="monotone"
dataKey="skor"
stroke="#ff3b1f"
strokeWidth={3}
dot={{ fill: "#ff3b1f", r: 4 }}
activeDot={{ r: 6 }}
name="Rata-rata Skor Fuzzy"
connectNulls
/>
<Line
type="monotone"
dataKey="pendataan"
stroke="#5683d2"
strokeWidth={2}
dot={{ fill: "#5683d2", r: 3 }}
activeDot={{ r: 5 }}
name="Pendataan Baru (KK)"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
"use client";
interface RadiusSliderProps {
value: number;
onChange: (value: number) => void;
}
export function RadiusSlider({ value, onChange }: RadiusSliderProps) {
const pct = (value / 2000) * 100;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={2000}
step={50}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: value === 0
? "var(--border)"
: `linear-gradient(to right, #10b981 0%, #10b981 ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-12 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color: value === 0 ? "var(--muted-foreground)" : "#10b981" }}
>
{value === 0 ? "—" : `${value}m`}
</span>
</div>
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
</div>
);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
interface PendingClick { lat: number; lng: number; }
interface ReligionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
export function ReligionDialog({
open,
onOpenChange,
pendingClick,
onConfirm,
}: ReligionDialogProps) {
const [religionType, setReligionType] = useState<string | null>(null);
const [placeName, setPlaceName] = useState("");
const [radiusMeters, setRadiusMeters] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
function reset() {
setReligionType(null);
setPlaceName("");
setRadiusMeters(0);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick || !religionType) return;
setIsSubmitting(true);
setError(null);
try {
const token = localStorage.getItem("auth_token");
const label = RELIGION_OPTIONS.find((o) => o.value === religionType)?.label ?? religionType;
const name = placeName.trim() || label;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: "religion",
religion_subtype: religionType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
radius_meters: radiusMeters,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: religionType,
name,
meta: { poi_type: "religion", notes: saved.notes ?? undefined, radius: saved.radius_meters ?? 0 },
});
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-sm w-full rounded-2xl p-0 gap-0"
>
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Tambah Tempat Ibadah
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
<Field label="Nama Tempat Ibadah">
<Input
placeholder="cth. Masjid Al-Ikhlas"
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || !religionType}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}