tambah fitur autoformat rupiah dan rumah ibadah otomatis terdekat
This commit is contained in:
+131
-50
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user