tambah fitur autoformat rupiah dan rumah ibadah otomatis terdekat

This commit is contained in:
2026-05-16 21:14:02 +07:00
parent 76d6888b90
commit 36c8f4ea5c
+131 -50
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { trpc } from "@/providers/trpc"; import { trpc } from "@/providers/trpc";
import { LocationPicker } from "@/components/LocationPicker"; import { LocationPicker } from "@/components/LocationPicker";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -44,6 +44,38 @@ const STATUS_OPTIONS = [
{ value: "suspended", label: "Ditangguhkan" }, { 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() { export default function RecipientsPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
@@ -60,7 +92,7 @@ export default function RecipientsPage() {
phone: "", phone: "",
familyMembers: 1, familyMembers: 1,
incomePerMonth: 0, incomePerMonth: 0,
placeOfWorshipId: 1, placeOfWorshipId: 0,
notes: "", notes: "",
latitude: "", latitude: "",
longitude: "", longitude: "",
@@ -74,6 +106,24 @@ export default function RecipientsPage() {
const { data: places } = trpc.placeOfWorship.list.useQuery({}); 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({ const createMutation = trpc.recipient.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.recipient.list.invalidate(); utils.recipient.list.invalidate();
@@ -108,7 +158,7 @@ export default function RecipientsPage() {
phone: "", phone: "",
familyMembers: 1, familyMembers: 1,
incomePerMonth: 0, incomePerMonth: 0,
placeOfWorshipId: 1, placeOfWorshipId: 0,
notes: "", notes: "",
latitude: "", latitude: "",
longitude: "", longitude: "",
@@ -118,7 +168,8 @@ export default function RecipientsPage() {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!formData.nik || !formData.name) return; const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return;
createMutation.mutate({ createMutation.mutate({
nik: formData.nik, nik: formData.nik,
name: formData.name, name: formData.name,
@@ -128,7 +179,7 @@ export default function RecipientsPage() {
phone: formData.phone || undefined, phone: formData.phone || undefined,
familyMembers: formData.familyMembers, familyMembers: formData.familyMembers,
incomePerMonth: formData.incomePerMonth, incomePerMonth: formData.incomePerMonth,
placeOfWorshipId: formData.placeOfWorshipId, placeOfWorshipId: resolvedPlaceOfWorshipId,
notes: formData.notes || undefined, notes: formData.notes || undefined,
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
@@ -156,6 +207,7 @@ export default function RecipientsPage() {
const handleUpdate = (e: React.FormEvent) => { const handleUpdate = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
if (!selectedRecipient) return; if (!selectedRecipient) return;
updateMutation.mutate({ updateMutation.mutate({
id: selectedRecipient, id: selectedRecipient,
@@ -167,7 +219,7 @@ export default function RecipientsPage() {
phone: formData.phone || undefined, phone: formData.phone || undefined,
familyMembers: formData.familyMembers, familyMembers: formData.familyMembers,
incomePerMonth: formData.incomePerMonth, incomePerMonth: formData.incomePerMonth,
placeOfWorshipId: formData.placeOfWorshipId, placeOfWorshipId: resolvedPlaceOfWorshipId,
notes: formData.notes || undefined, notes: formData.notes || undefined,
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined, latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined, longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
@@ -190,6 +242,46 @@ export default function RecipientsPage() {
setFormData((current) => ({ ...current, ...next })); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -260,29 +352,20 @@ export default function RecipientsPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Pendapatan/Bulan (Rp)</Label> <Label className="text-xs">Pendapatan/Bulan (Rp)</Label>
<Input type="number" min={0} value={formData.incomePerMonth} onChange={(e) => updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> <Input
</div> value={formatRupiah(formData.incomePerMonth)}
<div className="space-y-1.5"> onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
<Label className="text-xs">Rumah Ibadah</Label> className="h-9"
<Select value={String(formData.placeOfWorshipId)} onValueChange={(v) => updateFormData({ placeOfWorshipId: parseInt(v) })}> />
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(places ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<div className="space-y-1.5"> <p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
<Label className="text-xs">Latitude</Label> <p>
<Input value={formData.latitude} onChange={(e) => updateFormData({ latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" /> {nearestPlaceForForm
</div> ? `${nearestPlaceForForm.name}${formatDistance(nearestPlaceForForm.distanceKm)}`
<div className="space-y-1.5"> : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
<Label className="text-xs">Longitude</Label> </p>
<Input value={formData.longitude} onChange={(e) => updateFormData({ longitude: e.target.value })} placeholder="106.xxxx" className="h-9" />
</div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Catatan</Label> <Label className="text-xs">Catatan</Label>
@@ -349,6 +432,7 @@ export default function RecipientsPage() {
<TableHead className="text-xs">Nama</TableHead> <TableHead className="text-xs">Nama</TableHead>
<TableHead className="text-xs">Status</TableHead> <TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs">Rumah Ibadah</TableHead> <TableHead className="text-xs">Rumah Ibadah</TableHead>
<TableHead className="text-xs">Jarak</TableHead>
<TableHead className="text-xs">Keluarga</TableHead> <TableHead className="text-xs">Keluarga</TableHead>
<TableHead className="text-xs">Pendapatan</TableHead> <TableHead className="text-xs">Pendapatan</TableHead>
<TableHead className="text-xs">Aksi</TableHead> <TableHead className="text-xs">Aksi</TableHead>
@@ -363,9 +447,15 @@ export default function RecipientsPage() {
<TableCell className="text-xs"> <TableCell className="text-xs">
{places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"} {places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"}
</TableCell> </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">{r.familyMembers}</TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
Rp {(r.incomePerMonth ?? 0).toLocaleString("id-ID")} {formatRupiah(r.incomePerMonth ?? 0)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -459,32 +549,23 @@ export default function RecipientsPage() {
<Input type="number" value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" /> <Input type="number" value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Pendapatan/Bulan</Label> <Label className="text-xs">Pendapatan/Bulan</Label>
<Input type="number" value={formData.incomePerMonth} onChange={(e) => updateFormData({ incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" /> <Input
</div> value={formatRupiah(formData.incomePerMonth)}
<div className="space-y-1.5"> onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
<Label className="text-xs">Rumah Ibadah</Label> className="h-9"
<Select value={String(formData.placeOfWorshipId)} onValueChange={(v) => updateFormData({ placeOfWorshipId: parseInt(v) })}> />
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(places ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<div className="space-y-1.5"> <p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
<Label className="text-xs">Latitude</Label> <p>
<Input value={formData.latitude} onChange={(e) => updateFormData({ latitude: e.target.value })} className="h-9" /> {nearestPlaceForForm
</div> ? `${nearestPlaceForForm.name}${formatDistance(nearestPlaceForForm.distanceKm)}`
<div className="space-y-1.5"> : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
<Label className="text-xs">Longitude</Label> </p>
<Input value={formData.longitude} onChange={(e) => updateFormData({ longitude: e.target.value })} className="h-9" />
</div>
</div> </div>
<Button type="submit" className="w-full" disabled={updateMutation.isPending}> <Button type="submit" className="w-full" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Memperbarui..." : "Perbarui"} {updateMutation.isPending ? "Memperbarui..." : "Perbarui"}