tambah akaun dari tiap role
This commit is contained in:
+45
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user