tambah akaun dari tiap role

This commit is contained in:
2026-05-17 00:53:43 +07:00
parent c205a89b88
commit a904a27934
7 changed files with 330 additions and 243 deletions
+45 -14
View File
@@ -113,30 +113,61 @@ export async function seedLocalUserIfMissing() {
// Check existence without selecting `password_hash` to avoid failures on older schemas
try {
const [rows] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
if (Array.isArray(rows) && rows.length > 0) return;
} catch (err) {
console.warn('[seed] existence check failed, will attempt to insert anyway', err);
}
// Try to insert using the typed Drizzle insert (preferred). If that fails because the
// `password_hash` column is missing, fall back to a raw INSERT that omits the column.
// 1. Ensure admin account
try {
await db.insert(users).values({
unionId: LocalAuth.defaultAdminEmail,
name: "Admin Lokal",
email: LocalAuth.defaultAdminEmail,
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
role: "admin",
lastSignInAt: new Date(),
});
const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) {
await db.insert(users).values({
unionId: LocalAuth.defaultAdminEmail,
name: "Admin Lokal",
email: LocalAuth.defaultAdminEmail,
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
role: "admin",
lastSignInAt: new Date(),
});
console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`);
}
} catch (err) {
console.warn('[seed] typed insert failed, attempting fallback insert without password_hash', err);
console.warn('[seed] Admin seeding failed, attempting fallback', err);
try {
await db.execute(sql`INSERT INTO users (unionId, name, email, role, lastSignInAt) VALUES (
await db.execute(sql`INSERT IGNORE INTO users (unionId, name, email, role, lastSignInAt) VALUES (
${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()}
)`);
} catch (err2) {
console.error('[seed] fallback insert also failed', err2);
console.error('[seed] Admin fallback failed', err2);
}
}
// 2. Seed default roles for testing
const defaultAccounts = [
{ email: "officer@example.com", name: "Petugas Lapangan", role: "officer" as const },
{ email: "manager@example.com", name: "Pengelola Wilayah", role: "manager" as const },
];
for (const acc of defaultAccounts) {
try {
const res = await db.execute(sql`SELECT id FROM users WHERE unionId = ${acc.email} LIMIT 1`);
const [existing] = res as any;
if (existing && (!Array.isArray(existing) || existing.length > 0)) {
continue;
}
await db.insert(users).values({
unionId: acc.email,
name: acc.name,
email: acc.email,
passwordHash: hashPassword("password123"),
role: acc.role,
lastSignInAt: new Date(),
});
console.log(`[seed] Created ${acc.role} account: ${acc.email}`);
} catch (err) {
console.warn(`[seed] Failed to create ${acc.role} (${acc.email})`, err);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { sql } from "drizzle-orm";
import { getDb } from "../api/queries/connection";
import { users } from "../db/schema";
async function check() {
const db = getDb();
const res = await db.execute(sql`SELECT id FROM users WHERE unionId = 'officer@example.com' LIMIT 1`);
console.log("Raw query result for officer:", res);
const allUsers = await db.select().from(users);
console.log("Current Users in DB:");
console.table(allUsers.map(u => ({ id: u.id, email: u.email, role: u.role })));
process.exit(0);
}
check().catch(console.error);
+10
View File
@@ -0,0 +1,10 @@
import { seedLocalUserIfMissing } from "../api/auth";
async function forceSeed() {
console.log("Starting force seed...");
await seedLocalUserIfMissing();
console.log("Force seed completed.");
process.exit(0);
}
forceSeed().catch(console.error);
+11 -5
View File
@@ -11,7 +11,13 @@ import VerificationPage from "./pages/VerificationPage";
import UsersPage from "./pages/UsersPage";
import SettingsPage from "./pages/SettingsPage";
function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) {
function ProtectedRoute({
children,
allowedRoles,
}: {
children: React.ReactNode;
allowedRoles?: string[];
}) {
const { user, isLoading } = useAuth();
if (isLoading) {
@@ -26,7 +32,7 @@ function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; ad
return <Navigate to="/login" replace />;
}
if (adminOnly && user.role !== "admin") {
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/" replace />;
}
@@ -72,7 +78,7 @@ export default function App() {
<Route
path="/verification"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={["admin", "officer"]}>
<VerificationPage />
</ProtectedRoute>
}
@@ -80,7 +86,7 @@ export default function App() {
<Route
path="/users"
element={
<ProtectedRoute adminOnly>
<ProtectedRoute allowedRoles={["admin"]}>
<UsersPage />
</ProtectedRoute>
}
@@ -88,7 +94,7 @@ export default function App() {
<Route
path="/settings"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={["admin"]}>
<SettingsPage />
</ProtectedRoute>
}
+9 -10
View File
@@ -33,9 +33,14 @@ const menuItems = [
{ icon: Map, label: "Peta GIS", path: "/map" },
{ icon: Users, label: "Penerima Bantuan", path: "/recipients" },
{ icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" },
{ icon: ClipboardCheck, label: "Verifikasi", path: "/verification" },
{ icon: Shield, label: "Pengguna", path: "/users", adminOnly: true },
{ icon: Settings, label: "Pengaturan", path: "/settings" },
{
icon: ClipboardCheck,
label: "Verifikasi",
path: "/verification",
roles: ["admin", "officer"],
},
{ icon: Shield, label: "Pengguna", path: "/users", roles: ["admin"] },
{ icon: Settings, label: "Pengaturan", path: "/settings", roles: ["admin"] },
];
export default function AppLayout({ children }: { children: ReactNode }) {
@@ -46,11 +51,10 @@ export default function AppLayout({ children }: { children: ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const isAdmin = user?.role === "admin";
const activeItem = menuItems.find((item) => item.path === location.pathname);
const filteredItems = menuItems.filter(
(item) => !item.adminOnly || isAdmin
(item) => !item.roles || (user?.role && item.roles.includes(user.role))
);
const sidebarWidth = collapsed ? 72 : 260;
@@ -95,11 +99,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
{!collapsed && item.adminOnly && (
<Badge variant="outline" className="ml-auto text-[9px] px-1 py-0 h-4">
Admin
</Badge>
)}
</button>
);
})}
+95 -81
View File
@@ -59,6 +59,12 @@ function parseRupiahInput(input: string) {
}
export default function PlacesOfWorshipPage() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const isManager = user?.role === "manager";
const canModify = isAdmin; // Only Admin can modify Places of Worship
const canDistribute = isAdmin || isManager; // Admin and Manager can distribute aid
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -243,79 +249,81 @@ export default function PlacesOfWorshipPage() {
<h2 className="text-2xl font-bold tracking-tight">Rumah Ibadah</h2>
<p className="text-muted-foreground mt-1 text-sm">Kelola data rumah ibadah dan cakupan wilayahnya</p>
</div>
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button className="rounded-xl" size="sm">
<Plus className="h-4 w-4 mr-2" />
Tambah Rumah Ibadah
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Tambah Rumah Ibadah</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Nama Tempat Ibadah</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tipe</Label>
<Select value={formData.type} onValueChange={(v: typeof formData.type) => setFormData({ ...formData, type: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mosque">Masjid</SelectItem>
<SelectItem value="church">Gereja</SelectItem>
<SelectItem value="temple">Pura</SelectItem>
<SelectItem value="vihara">Vihara</SelectItem>
<SelectItem value="other">Lainnya</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Kapasitas</Label>
<Input type="number" min={1} value={formData.capacity} onChange={(e) => setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih lokasi rumah ibadah"
hint="Klik peta atau geser pin. Koordinat dan alamat akan terisi otomatis."
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Latitude</Label>
<Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Longitude</Label>
<Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Radius (meter)</Label>
<Input type="number" min={100} max={10000} value={formData.radius} onChange={(e) => setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">No. Kontak</Label>
<Input value={formData.contactPhone} onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Nama Kontak</Label>
<Input value={formData.contactName} onChange={(e) => setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" />
</div>
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
{canModify && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button className="rounded-xl" size="sm">
<Plus className="h-4 w-4 mr-2" />
Tambah Rumah Ibadah
</Button>
</form>
</DialogContent>
</Dialog>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Tambah Rumah Ibadah</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Nama Tempat Ibadah</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tipe</Label>
<Select value={formData.type} onValueChange={(v: typeof formData.type) => setFormData({ ...formData, type: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mosque">Masjid</SelectItem>
<SelectItem value="church">Gereja</SelectItem>
<SelectItem value="temple">Pura</SelectItem>
<SelectItem value="vihara">Vihara</SelectItem>
<SelectItem value="other">Lainnya</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Kapasitas</Label>
<Input type="number" min={1} value={formData.capacity} onChange={(e) => setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih lokasi rumah ibadah"
hint="Klik peta atau geser pin. Koordinat dan alamat akan terisi otomatis."
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Latitude</Label>
<Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Longitude</Label>
<Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Radius (meter)</Label>
<Input type="number" min={100} max={10000} value={formData.radius} onChange={(e) => setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">No. Kontak</Label>
<Input value={formData.contactPhone} onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Nama Kontak</Label>
<Input value={formData.contactName} onChange={(e) => setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" />
</div>
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
</Button>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Filters */}
@@ -385,15 +393,21 @@ export default function PlacesOfWorshipPage() {
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setSelectedPlace(place.id); setIsViewOpen(true); }}>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="secondary" size="sm" className="h-7 px-2 text-xs rounded-lg" onClick={() => { setSelectedPlace(place.id); setIsAidOpen(true); }}>
Bantuan
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => { if (confirm("Hapus rumah ibadah ini?")) deleteMutation.mutate({ id: place.id }); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{canModify && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canDistribute && (
<Button variant="secondary" size="sm" className="h-7 px-2 text-xs rounded-lg" onClick={() => { setSelectedPlace(place.id); setIsAidOpen(true); }}>
Bantuan
</Button>
)}
{canModify && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => { if (confirm("Hapus rumah ibadah ini?")) deleteMutation.mutate({ id: place.id }); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</CardContent>
+144 -133
View File
@@ -78,6 +78,11 @@ function parseRupiahInput(input: string) {
}
export default function RecipientsPage() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const isManager = user?.role === "manager";
const canModify = isAdmin || isManager;
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -308,120 +313,122 @@ export default function RecipientsPage() {
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"}
{canModify && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button className="rounded-xl" size="sm">
<Plus className="h-4 w-4 mr-2" />
Tambah Penerima
</Button>
</form>
</DialogContent>
</Dialog>
</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 */}
@@ -512,26 +519,30 @@ export default function RecipientsPage() {
>
<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>
{canModify && (
<>
<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>