693 lines
29 KiB
TypeScript
693 lines
29 KiB
TypeScript
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<number | null>(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<typeof place> => 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<typeof recipients>[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 <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 capitalize text-xs"><CheckCircle className="h-3 w-3 mr-1" />Aktif</Badge>;
|
|
case "pending": return <Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 capitalize text-xs"><AlertCircle className="h-3 w-3 mr-1" />Menunggu</Badge>;
|
|
case "rejected": return <Badge className="bg-red-100 text-red-700 hover:bg-red-100 capitalize text-xs"><XCircle className="h-3 w-3 mr-1" />Ditolak</Badge>;
|
|
case "suspended": return <Badge variant="secondary" className="capitalize text-xs">Ditangguhkan</Badge>;
|
|
default: return <Badge variant="outline" className="capitalize text-xs">{status}</Badge>;
|
|
}
|
|
};
|
|
|
|
const selectedRec = recipients?.find((r) => r.id === selectedRecipient);
|
|
|
|
const updateFormData = (next: Partial<typeof formData>) => {
|
|
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<typeof recipients>[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 (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">Penerima Bantuan</h2>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Kelola data warga penerima bantuan sosial
|
|
</p>
|
|
</div>
|
|
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button className="rounded-xl" size="sm">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Tambah Penerima
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Tambah Penerima Baru</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">NIK</Label>
|
|
<Input value={formData.nik} onChange={(e) => setFormData({ ...formData, nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Nama Lengkap</Label>
|
|
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Nama" className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Tanggal Lahir</Label>
|
|
<Input type="date" value={formData.birthDate} onChange={(e) => setFormData({ ...formData, birthDate: e.target.value })} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Jenis Kelamin</Label>
|
|
<Select value={formData.gender} onValueChange={(v: "male" | "female") => setFormData({ ...formData, gender: v })}>
|
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="male">Laki-laki</SelectItem>
|
|
<SelectItem value="female">Perempuan</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Alamat</Label>
|
|
<Input value={formData.address} onChange={(e) => updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
|
|
</div>
|
|
<LocationPicker
|
|
value={formData}
|
|
onChange={updateFormData}
|
|
label="Pilih titik lokasi dari peta"
|
|
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
|
/>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<DocumentUploadField
|
|
label="Foto KTP (Kartu Tanda Penduduk)"
|
|
value={formData.ktpDocument}
|
|
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
|
|
required
|
|
helperText="JPG, PNG, PDF - Maks 5MB"
|
|
/>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<DocumentUploadField
|
|
label="Foto Kartu Keluarga (KK)"
|
|
value={formData.kkDocument}
|
|
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
|
|
required
|
|
helperText="JPG, PNG, PDF"
|
|
/>
|
|
<DocumentUploadField
|
|
label="Surat Keterangan Tidak Mampu (SKTM)"
|
|
value={formData.sktmDocument}
|
|
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
|
|
helperText="JPG, PNG, PDF (Opsional)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Telepon</Label>
|
|
<Input value={formData.phone} onChange={(e) => updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Jumlah Keluarga</Label>
|
|
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Pendapatan/Bulan (Rp)</Label>
|
|
<Input
|
|
value={formatRupiah(formData.incomePerMonth)}
|
|
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
|
<p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
|
|
<p>
|
|
{nearestPlaceForForm
|
|
? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}`
|
|
: "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Catatan</Label>
|
|
<Input value={formData.notes} onChange={(e) => updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={createMutation.isPending || !formData.ktpDocument || !formData.kkDocument}
|
|
>
|
|
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="rounded-2xl shadow-sm border-0">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Cari nama atau NIK..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[140px] h-9 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Table */}
|
|
<Card className="rounded-2xl shadow-sm border-0">
|
|
<CardContent className="p-0">
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
) : (recipients ?? []).length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground">
|
|
<Users className="h-10 w-10 mx-auto mb-3 text-muted" />
|
|
<p>Belum ada data penerima</p>
|
|
<p className="text-xs mt-1">Tambahkan penerima baru untuk memulai</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">NIK</TableHead>
|
|
<TableHead className="text-xs">Nama</TableHead>
|
|
<TableHead className="text-xs">Status</TableHead>
|
|
<TableHead className="text-xs">Rumah Ibadah</TableHead>
|
|
<TableHead className="text-xs">Jarak</TableHead>
|
|
<TableHead className="text-xs">Keluarga</TableHead>
|
|
<TableHead className="text-xs">Pendapatan</TableHead>
|
|
<TableHead className="text-xs">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(recipients ?? []).map((r) => (
|
|
<TableRow key={r.id} className="hover:bg-muted/30">
|
|
<TableCell className="text-xs font-mono">{r.nik}</TableCell>
|
|
<TableCell className="text-xs font-medium">{r.name}</TableCell>
|
|
<TableCell>{statusBadge(r.status)}</TableCell>
|
|
<TableCell className="text-xs">
|
|
{places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{(() => {
|
|
const distanceKm = getRecipientDistanceToPlace(r);
|
|
return distanceKm === null ? "-" : formatDistance(distanceKm);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell className="text-xs">{r.familyMembers}</TableCell>
|
|
<TableCell className="text-xs">
|
|
{formatRupiah(r.incomePerMonth ?? 0)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => { setSelectedRecipient(r.id); setIsViewOpen(true); }}
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleEdit(r)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => {
|
|
if (confirm("Hapus penerima ini?")) {
|
|
deleteMutation.mutate({ id: r.id });
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader><DialogTitle>Edit Penerima</DialogTitle></DialogHeader>
|
|
<form onSubmit={handleUpdate} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">NIK</Label>
|
|
<Input value={formData.nik} onChange={(e) => setFormData({ ...formData, nik: e.target.value })} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Nama</Label>
|
|
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Tanggal Lahir</Label>
|
|
<Input type="date" value={formData.birthDate} onChange={(e) => setFormData({ ...formData, birthDate: e.target.value })} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Jenis Kelamin</Label>
|
|
<Select value={formData.gender} onValueChange={(v: "male" | "female") => setFormData({ ...formData, gender: v })}>
|
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="male">Laki-laki</SelectItem>
|
|
<SelectItem value="female">Perempuan</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Alamat</Label>
|
|
<Input value={formData.address} onChange={(e) => updateFormData({ address: e.target.value })} className="h-9" />
|
|
</div>
|
|
<LocationPicker
|
|
value={formData}
|
|
onChange={updateFormData}
|
|
label="Pilih titik lokasi dari peta"
|
|
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
|
/>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<DocumentUploadField
|
|
label="Foto KTP (Kartu Tanda Penduduk)"
|
|
value={formData.ktpDocument}
|
|
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
|
|
required
|
|
helperText="JPG, PNG, PDF - Maks 5MB"
|
|
/>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<DocumentUploadField
|
|
label="Foto Kartu Keluarga (KK)"
|
|
value={formData.kkDocument}
|
|
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
|
|
required
|
|
helperText="JPG, PNG, PDF"
|
|
/>
|
|
<DocumentUploadField
|
|
label="Surat Keterangan Tidak Mampu (SKTM)"
|
|
value={formData.sktmDocument}
|
|
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
|
|
helperText="JPG, PNG, PDF (Opsional)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Telepon</Label>
|
|
<Input value={formData.phone} onChange={(e) => updateFormData({ phone: e.target.value })} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Jumlah Keluarga</Label>
|
|
<Input type="number" value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Pendapatan/Bulan</Label>
|
|
<Input
|
|
value={formatRupiah(formData.incomePerMonth)}
|
|
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
|
<p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
|
|
<p>
|
|
{nearestPlaceForForm
|
|
? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}`
|
|
: "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
|
|
</p>
|
|
</div>
|
|
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
|
|
{updateMutation.isPending ? "Memperbarui..." : "Perbarui"}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* View Dialog */}
|
|
<Dialog open={isViewOpen} onOpenChange={setIsViewOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader><DialogTitle>Detail Penerima</DialogTitle></DialogHeader>
|
|
{selectedRec && (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-semibold text-lg">{selectedRec.name}</span>
|
|
{statusBadge(selectedRec.status)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div><span className="text-muted-foreground">NIK:</span> {selectedRec.nik}</div>
|
|
<div><span className="text-muted-foreground">Telepon:</span> {selectedRec.phone ?? "-"}</div>
|
|
<div><span className="text-muted-foreground">Tgl Lahir:</span> {selectedRec.birthDate ?? "-"}</div>
|
|
<div><span className="text-muted-foreground">JK:</span> {selectedRec.gender === "male" ? "Laki-laki" : "Perempuan"}</div>
|
|
<div><span className="text-muted-foreground">Keluarga:</span> {selectedRec.familyMembers} orang</div>
|
|
<div><span className="text-muted-foreground">Pendapatan:</span> Rp {(selectedRec.incomePerMonth ?? 0).toLocaleString()}</div>
|
|
</div>
|
|
{selectedRec.address && (
|
|
<div className="text-xs"><span className="text-muted-foreground">Alamat:</span> {selectedRec.address}</div>
|
|
)}
|
|
{selectedRec.latitude && selectedRec.longitude && (
|
|
<div className="text-xs flex items-center gap-1 text-muted-foreground">
|
|
<MapPin className="h-3 w-3" />
|
|
{selectedRec.latitude}, {selectedRec.longitude}
|
|
</div>
|
|
)}
|
|
{selectedRec.notes && (
|
|
<div className="text-xs bg-muted p-2 rounded-lg"><span className="text-muted-foreground">Catatan:</span> {selectedRec.notes}</div>
|
|
)}
|
|
{(selectedRec.ktpDocument || selectedRec.kkDocument || selectedRec.sktmDocument) && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium">Dokumen Terunggah</p>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<DocumentUploadField label="KTP" value={selectedRec.ktpDocument} />
|
|
<DocumentUploadField label="KK" value={selectedRec.kkDocument} />
|
|
<DocumentUploadField label="SKTM" value={selectedRec.sktmDocument} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|