feat: initial commit with seeded database and premium ui changes
This commit is contained in:
@@ -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)}, {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)}, {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 0–100 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 1–10 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)}, {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user