import { useEffect, useMemo, useState } from "react"; import { trpc } from "@/providers/trpc"; import { LocationPicker } from "@/components/LocationPicker"; import { DocumentUploadField } from "@/components/DocumentUploadField"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Search, Plus, MapPin, Pencil, Trash2, Users, CheckCircle, AlertCircle, XCircle, Eye, } from "lucide-react"; const STATUS_OPTIONS = [ { value: "all", label: "Semua" }, { value: "active", label: "Aktif" }, { value: "pending", label: "Menunggu" }, { value: "rejected", label: "Ditolak" }, { value: "suspended", label: "Ditangguhkan" }, ]; function haversineDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number) { const toRad = (deg: number) => (deg * Math.PI) / 180; const earthRadiusKm = 6371; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthRadiusKm * c; } function formatDistance(distanceKm: number) { if (distanceKm < 1) { return `${Math.round(distanceKm * 1000)} m`; } return `${distanceKm.toFixed(2)} km`; } function formatRupiah(amount: number) { return new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0, }).format(amount); } function parseRupiahInput(input: string) { const digits = input.replace(/\D/g, ""); return digits ? Number.parseInt(digits, 10) : 0; } export default function RecipientsPage() { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [isAddOpen, setIsAddOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false); const [isViewOpen, setIsViewOpen] = useState(false); const [selectedRecipient, setSelectedRecipient] = useState(null); const [formData, setFormData] = useState({ nik: "", name: "", birthDate: "", gender: "male" as "male" | "female", address: "", phone: "", familyMembers: 1, incomePerMonth: 0, placeOfWorshipId: 0, notes: "", latitude: "", longitude: "", ktpDocument: "", kkDocument: "", sktmDocument: "", }); const utils = trpc.useUtils(); const { data: recipients, isLoading } = trpc.recipient.list.useQuery({ search: search || undefined, status: statusFilter === "all" ? undefined : statusFilter, }); const { data: places } = trpc.placeOfWorship.list.useQuery({}); const placesWithCoordinates = useMemo(() => { return (places ?? []) .map((place) => { const latitude = Number.parseFloat(String(place.latitude)); const longitude = Number.parseFloat(String(place.longitude)); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { return null; } return { id: place.id, name: place.name, latitude, longitude, }; }) .filter((place): place is NonNullable => place !== null); }, [places]); const createMutation = trpc.recipient.create.useMutation({ onSuccess: () => { utils.recipient.list.invalidate(); utils.dashboard.stats.invalidate(); setIsAddOpen(false); resetForm(); }, }); const updateMutation = trpc.recipient.update.useMutation({ onSuccess: () => { utils.recipient.list.invalidate(); setIsEditOpen(false); resetForm(); }, }); const deleteMutation = trpc.recipient.delete.useMutation({ onSuccess: () => { utils.recipient.list.invalidate(); utils.dashboard.stats.invalidate(); }, }); const resetForm = () => { setFormData({ nik: "", name: "", birthDate: "", gender: "male", address: "", phone: "", familyMembers: 1, incomePerMonth: 0, placeOfWorshipId: 0, notes: "", latitude: "", longitude: "", ktpDocument: "", kkDocument: "", sktmDocument: "", }); setSelectedRecipient(null); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return; if (!formData.ktpDocument || !formData.kkDocument) return; createMutation.mutate({ nik: formData.nik, name: formData.name, birthDate: formData.birthDate || undefined, gender: formData.gender, address: formData.address || undefined, phone: formData.phone || undefined, familyMembers: formData.familyMembers, incomePerMonth: formData.incomePerMonth, placeOfWorshipId: resolvedPlaceOfWorshipId, notes: formData.notes || undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, ktpDocument: formData.ktpDocument, kkDocument: formData.kkDocument, sktmDocument: formData.sktmDocument || undefined, }); }; const handleEdit = (recipient: NonNullable[number]) => { setFormData({ nik: recipient.nik, name: recipient.name, birthDate: recipient.birthDate ?? "", gender: (recipient.gender as "male" | "female") ?? "male", address: recipient.address ?? "", phone: recipient.phone ?? "", familyMembers: recipient.familyMembers ?? 1, incomePerMonth: recipient.incomePerMonth ?? 0, placeOfWorshipId: recipient.placeOfWorshipId, notes: recipient.notes ?? "", latitude: recipient.latitude?.toString() ?? "", longitude: recipient.longitude?.toString() ?? "", ktpDocument: recipient.ktpDocument ?? "", kkDocument: recipient.kkDocument ?? "", sktmDocument: recipient.sktmDocument ?? "", }); setSelectedRecipient(recipient.id); setIsEditOpen(true); }; const handleUpdate = (e: React.FormEvent) => { e.preventDefault(); const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; if (!selectedRecipient) return; updateMutation.mutate({ id: selectedRecipient, nik: formData.nik, name: formData.name, birthDate: formData.birthDate || undefined, gender: formData.gender, address: formData.address || undefined, phone: formData.phone || undefined, familyMembers: formData.familyMembers, incomePerMonth: formData.incomePerMonth, placeOfWorshipId: resolvedPlaceOfWorshipId, notes: formData.notes || undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, ktpDocument: formData.ktpDocument || undefined, kkDocument: formData.kkDocument || undefined, sktmDocument: formData.sktmDocument || undefined, }); }; const statusBadge = (status: string) => { switch (status) { case "active": return Aktif; case "pending": return Menunggu; case "rejected": return Ditolak; case "suspended": return Ditangguhkan; default: return {status}; } }; const selectedRec = recipients?.find((r) => r.id === selectedRecipient); const updateFormData = (next: Partial) => { setFormData((current) => ({ ...current, ...next })); }; const nearestPlaceForForm = useMemo(() => { const latitude = Number.parseFloat(formData.latitude); const longitude = Number.parseFloat(formData.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { return null; } let nearest: { id: number; name: string; distanceKm: number } | null = null; for (const place of placesWithCoordinates) { const distanceKm = haversineDistanceKm(latitude, longitude, place.latitude, place.longitude); if (!nearest || distanceKm < nearest.distanceKm) { nearest = { id: place.id, name: place.name, distanceKm }; } } return nearest; }, [formData.latitude, formData.longitude, placesWithCoordinates]); useEffect(() => { if (!nearestPlaceForForm) return; setFormData((current) => current.placeOfWorshipId === nearestPlaceForForm.id ? current : { ...current, placeOfWorshipId: nearestPlaceForForm.id }, ); }, [nearestPlaceForForm]); const getRecipientDistanceToPlace = (recipient: NonNullable[number]) => { const latitude = Number.parseFloat(String(recipient.latitude)); const longitude = Number.parseFloat(String(recipient.longitude)); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { return null; } const place = placesWithCoordinates.find((item) => item.id === recipient.placeOfWorshipId); if (!place) { return null; } return haversineDistanceKm(latitude, longitude, place.latitude, place.longitude); }; return (

Penerima Bantuan

Kelola data warga penerima bantuan sosial

Tambah Penerima Baru
setFormData({ ...formData, nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" />
setFormData({ ...formData, name: e.target.value })} placeholder="Nama" className="h-9" />
setFormData({ ...formData, birthDate: e.target.value })} className="h-9" />
updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
updateFormData({ ktpDocument: value ?? "" })} required helperText="JPG, PNG, PDF - Maks 5MB" />
updateFormData({ kkDocument: value ?? "" })} required helperText="JPG, PNG, PDF" /> updateFormData({ sktmDocument: value ?? "" })} helperText="JPG, PNG, PDF (Opsional)" />
updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} className="h-9" />

Rumah ibadah terdekat (otomatis)

{nearestPlaceForForm ? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}` : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}

updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
{/* Filters */}
setSearch(e.target.value)} className="pl-9 h-9" />
{/* Table */} {isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : (recipients ?? []).length === 0 ? (

Belum ada data penerima

Tambahkan penerima baru untuk memulai

) : (
NIK Nama Status Rumah Ibadah Jarak Keluarga Pendapatan Aksi {(recipients ?? []).map((r) => ( {r.nik} {r.name} {statusBadge(r.status)} {places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"} {(() => { const distanceKm = getRecipientDistanceToPlace(r); return distanceKm === null ? "-" : formatDistance(distanceKm); })()} {r.familyMembers} {formatRupiah(r.incomePerMonth ?? 0)}
))}
)}
{/* Edit Dialog */} Edit Penerima
setFormData({ ...formData, nik: e.target.value })} className="h-9" />
setFormData({ ...formData, name: e.target.value })} className="h-9" />
setFormData({ ...formData, birthDate: e.target.value })} className="h-9" />
updateFormData({ address: e.target.value })} className="h-9" />
updateFormData({ ktpDocument: value ?? "" })} required helperText="JPG, PNG, PDF - Maks 5MB" />
updateFormData({ kkDocument: value ?? "" })} required helperText="JPG, PNG, PDF" /> updateFormData({ sktmDocument: value ?? "" })} helperText="JPG, PNG, PDF (Opsional)" />
updateFormData({ phone: e.target.value })} className="h-9" />
updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} className="h-9" />

Rumah ibadah terdekat (otomatis)

{nearestPlaceForForm ? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}` : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}

{/* View Dialog */} Detail Penerima {selectedRec && (
{selectedRec.name} {statusBadge(selectedRec.status)}
NIK: {selectedRec.nik}
Telepon: {selectedRec.phone ?? "-"}
Tgl Lahir: {selectedRec.birthDate ?? "-"}
JK: {selectedRec.gender === "male" ? "Laki-laki" : "Perempuan"}
Keluarga: {selectedRec.familyMembers} orang
Pendapatan: Rp {(selectedRec.incomePerMonth ?? 0).toLocaleString()}
{selectedRec.address && (
Alamat: {selectedRec.address}
)} {selectedRec.latitude && selectedRec.longitude && (
{selectedRec.latitude}, {selectedRec.longitude}
)} {selectedRec.notes && (
Catatan: {selectedRec.notes}
)} {(selectedRec.ktpDocument || selectedRec.kkDocument || selectedRec.sktmDocument) && (

Dokumen Terunggah

)}
)}
); }