diff --git a/src/pages/RecipientsPage.tsx b/src/pages/RecipientsPage.tsx index 4e7c159..b56a7db 100644 --- a/src/pages/RecipientsPage.tsx +++ b/src/pages/RecipientsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { trpc } from "@/providers/trpc"; import { LocationPicker } from "@/components/LocationPicker"; import { Card, CardContent } from "@/components/ui/card"; @@ -44,6 +44,38 @@ const STATUS_OPTIONS = [ { 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"); @@ -60,7 +92,7 @@ export default function RecipientsPage() { phone: "", familyMembers: 1, incomePerMonth: 0, - placeOfWorshipId: 1, + placeOfWorshipId: 0, notes: "", latitude: "", longitude: "", @@ -74,6 +106,24 @@ export default function RecipientsPage() { 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(); @@ -108,7 +158,7 @@ export default function RecipientsPage() { phone: "", familyMembers: 1, incomePerMonth: 0, - placeOfWorshipId: 1, + placeOfWorshipId: 0, notes: "", latitude: "", longitude: "", @@ -118,7 +168,8 @@ export default function RecipientsPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!formData.nik || !formData.name) return; + const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; + if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return; createMutation.mutate({ nik: formData.nik, name: formData.name, @@ -128,7 +179,7 @@ export default function RecipientsPage() { phone: formData.phone || undefined, familyMembers: formData.familyMembers, incomePerMonth: formData.incomePerMonth, - placeOfWorshipId: formData.placeOfWorshipId, + placeOfWorshipId: resolvedPlaceOfWorshipId, notes: formData.notes || undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, @@ -156,6 +207,7 @@ export default function RecipientsPage() { const handleUpdate = (e: React.FormEvent) => { e.preventDefault(); + const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; if (!selectedRecipient) return; updateMutation.mutate({ id: selectedRecipient, @@ -167,7 +219,7 @@ export default function RecipientsPage() { phone: formData.phone || undefined, familyMembers: formData.familyMembers, incomePerMonth: formData.incomePerMonth, - placeOfWorshipId: formData.placeOfWorshipId, + placeOfWorshipId: resolvedPlaceOfWorshipId, notes: formData.notes || undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, @@ -190,6 +242,46 @@ export default function RecipientsPage() { 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 (
@@ -260,29 +352,20 @@ export default function RecipientsPage() {
- updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> -
-
- - + updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} + className="h-9" + />
-
-
- - updateFormData({ latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" /> -
-
- - updateFormData({ longitude: e.target.value })} placeholder="106.xxxx" className="h-9" /> -
+
+

Rumah ibadah terdekat (otomatis)

+

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

@@ -349,6 +432,7 @@ export default function RecipientsPage() { Nama Status Rumah Ibadah + Jarak Keluarga Pendapatan Aksi @@ -363,9 +447,15 @@ export default function RecipientsPage() { {places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"} + + {(() => { + const distanceKm = getRecipientDistanceToPlace(r); + return distanceKm === null ? "-" : formatDistance(distanceKm); + })()} + {r.familyMembers} - Rp {(r.incomePerMonth ?? 0).toLocaleString("id-ID")} + {formatRupiah(r.incomePerMonth ?? 0)}
@@ -459,32 +549,23 @@ export default function RecipientsPage() { updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
-
+
- updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> -
-
- - + updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} + className="h-9" + />
-
-
- - updateFormData({ latitude: e.target.value })} className="h-9" /> -
-
- - updateFormData({ longitude: 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."} +