diff --git a/api/auth.ts b/api/auth.ts index 864ae59..efcce2f 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -112,7 +112,7 @@ 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`); + await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); } catch (err) { console.warn('[seed] existence check failed, will attempt to insert anyway', err); } diff --git a/api/queries/users.ts b/api/queries/users.ts index 9b73fb1..a8f8bf7 100644 --- a/api/queries/users.ts +++ b/api/queries/users.ts @@ -50,3 +50,39 @@ export async function upsertUser(data: InsertUser) { .values(values) .onDuplicateKeyUpdate({ set: updateSet }); } + +export async function findAllUsers() { + return getDb() + .select({ + id: schema.users.id, + unionId: schema.users.unionId, + name: schema.users.name, + email: schema.users.email, + role: schema.users.role, + avatar: schema.users.avatar, + createdAt: schema.users.createdAt, + updatedAt: schema.users.updatedAt, + lastSignInAt: schema.users.lastSignInAt, + }) + .from(schema.users) + .orderBy(schema.users.createdAt); +} + +export async function createUser(data: InsertUser) { + const [result] = await getDb().insert(schema.users).values(data); + return result; +} + +export async function updateUser(id: number, data: Partial) { + const result = await getDb() + .update(schema.users) + .set(data) + .where(eq(schema.users.id, id)); + return result; +} + +export async function deleteUser(id: number) { + await getDb() + .delete(schema.users) + .where(eq(schema.users.id, id)); +} diff --git a/api/router.ts b/api/router.ts index 112a24d..bbb33e4 100644 --- a/api/router.ts +++ b/api/router.ts @@ -4,6 +4,7 @@ import { recipientRouter } from "./recipientRouter"; import { distributionRouter } from "./distributionRouter"; import { dashboardRouter } from "./dashboardRouter"; import { seedRouter } from "./seedRouter"; +import { userRouter } from "./userRouter"; import { createRouter, publicQuery } from "./middleware"; export const appRouter = createRouter({ @@ -14,6 +15,7 @@ export const appRouter = createRouter({ distribution: distributionRouter, dashboard: dashboardRouter, seed: seedRouter, + user: userRouter, }); export type AppRouter = typeof appRouter; diff --git a/api/userRouter.ts b/api/userRouter.ts new file mode 100644 index 0000000..128ac96 --- /dev/null +++ b/api/userRouter.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { createRouter, adminQuery } from "./middleware"; +import { + findAllUsers, + createUser, + updateUser, + deleteUser, + findUserByLoginIdentifier, +} from "./queries/users"; +import { hashPassword } from "./auth"; +import { createActivityLog } from "./queries/activityLogs"; + +async function logActivitySafe(payload: Parameters[0]) { + try { + await createActivityLog(payload); + } catch (error) { + console.warn("[activity] failed to write user activity", error); + } +} + +export const userRouter = createRouter({ + list: adminQuery.query(async () => { + return findAllUsers(); + }), + + create: adminQuery + .input( + z.object({ + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(6), + role: z.enum(["admin", "officer", "manager"]), + }) + ) + .mutation(async ({ input, ctx }) => { + const existing = await findUserByLoginIdentifier(input.email); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email sudah terdaftar di sistem.", + }); + } + + const result = await createUser({ + unionId: input.email, + name: input.name, + email: input.email, + passwordHash: hashPassword(input.password), + role: input.role, + lastSignInAt: new Date(), + }); + + const insertedId = result.insertId; + + await logActivitySafe({ + userId: ctx.user.id, + action: "CREATE", + entityType: "user", + entityId: insertedId, + details: `Membuat akun baru: ${input.name} (${input.role})`, + }); + + return { success: true, id: insertedId }; + }), + + update: adminQuery + .input( + z.object({ + id: z.number(), + name: z.string().min(1).optional(), + email: z.string().email().optional(), + password: z.string().min(6).optional(), + role: z.enum(["admin", "officer", "manager"]).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const updatedFields: Record = {}; + + if (input.name !== undefined) updatedFields.name = input.name; + if (input.email !== undefined) { + const existing = await findUserByLoginIdentifier(input.email); + if (existing && existing.id !== input.id) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email sudah terdaftar untuk pengguna lain.", + }); + } + updatedFields.email = input.email; + updatedFields.unionId = input.email; + } + if (input.password !== undefined) { + updatedFields.passwordHash = hashPassword(input.password); + } + if (input.role !== undefined) updatedFields.role = input.role; + + await updateUser(input.id, updatedFields); + + await logActivitySafe({ + userId: ctx.user.id, + action: "UPDATE", + entityType: "user", + entityId: input.id, + details: `Memperbarui akun pengguna ID ${input.id}`, + }); + + return { success: true }; + }), + + delete: adminQuery + .input(z.object({ id: z.number() })) + .mutation(async ({ input, ctx }) => { + if (input.id === ctx.user.id) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Anda tidak dapat menghapus akun Anda sendiri.", + }); + } + + await deleteUser(input.id); + + await logActivitySafe({ + userId: ctx.user.id, + action: "DELETE", + entityType: "user", + entityId: input.id, + details: `Menghapus akun pengguna ID ${input.id}`, + }); + + return { success: true }; + }), +}); diff --git a/db/schema.ts b/db/schema.ts index 9fb7060..96ff6d6 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -4,6 +4,7 @@ import { serial, varchar, text, + longtext, timestamp, bigint, int, @@ -78,9 +79,9 @@ export const recipients = mysqlTable( verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }), verifiedAt: timestamp("verified_at"), notes: text("notes"), - ktpDocument: text("ktp_document"), - kkDocument: text("kk_document"), - sktmDocument: text("sktm_document"), + ktpDocument: longtext("ktp_document"), + kkDocument: longtext("kk_document"), + sktmDocument: longtext("sktm_document"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()), }, diff --git a/grep.exe.stackdump b/grep.exe.stackdump new file mode 100644 index 0000000..c5447d9 --- /dev/null +++ b/grep.exe.stackdump @@ -0,0 +1,15 @@ +Stack trace: +Frame Function Args +000FFFFBCE0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50) +00000000000 00210065045 (000FFFFC690, 00000000000, 000000001F4, 00000000000) +000FFFFC3F0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000) +00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFC810 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000) +000FFFFC810 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000) +000FFFFC810 00100429F25 (000FFFFCC7B, 000FFFFC848, 00000000007, 00000000001) +000FFFFC880 00100404B11 (00000000009, 002101FEA90, 000FFFFCB80, 00000000000) +000FFFFCA70 001004294B0 (00000000006, 000FFFFCD30, 000FFFFCC50, 000FFFFCC50) +000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000) +End of stack trace diff --git a/index.html b/index.html index f59dff6..6eea4ea 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ - - - - WebGIS Poverty Mapping - - - -
- - - + + + + + WebGIS Poverty Mapping + + + + +
+ + + + \ No newline at end of file diff --git a/scratch/alter_db.ts b/scratch/alter_db.ts new file mode 100644 index 0000000..8f30d19 --- /dev/null +++ b/scratch/alter_db.ts @@ -0,0 +1,18 @@ +import { getDb } from "../api/queries/connection"; +import { sql } from "drizzle-orm"; + +async function main() { + const db = getDb(); + try { + console.log("Altering recipients table columns to LONGTEXT..."); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN ktp_document LONGTEXT`); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN kk_document LONGTEXT`); + await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN sktm_document LONGTEXT`); + console.log("Database altered successfully!"); + } catch (err) { + console.error("ERROR ALTERING TABLE:", err); + } + process.exit(0); +} + +main().catch(console.error); diff --git a/scratch/test_insert.ts b/scratch/test_insert.ts new file mode 100644 index 0000000..e70599f --- /dev/null +++ b/scratch/test_insert.ts @@ -0,0 +1,15 @@ +import { getDb } from "../api/queries/connection"; +import { sql } from "drizzle-orm"; + +async function main() { + const db = getDb(); + try { + const [result] = await db.execute(sql`DESCRIBE recipients`); + console.log("RECIPIENTS TABLE STRUCTURE:", result); + } catch (err) { + console.error("ERROR DESCRIBING TABLE:", err); + } + process.exit(0); +} + +main().catch(console.error); diff --git a/src/grep.exe.stackdump b/src/grep.exe.stackdump new file mode 100644 index 0000000..5083dfe --- /dev/null +++ b/src/grep.exe.stackdump @@ -0,0 +1,15 @@ +Stack trace: +Frame Function Args +000FFFFBCB0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50) +00000000000 00210065045 (000FFFFC660, 00000000000, 000000001FC, 00000000000) +000FFFFC3C0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000) +00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFC7E0 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000) +000FFFFC7E0 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000) +000FFFFC7E0 00100429F25 (000FFFFCC6B, 000FFFFC818, 0000000000A, 00000000001) +000FFFFC850 00100404B11 (00000000005, 000FFFFCAB0, 000FFFFCC40, 00000000000) +000FFFFCA40 001004294B0 (000FFFFCC40, 000FFFFCC40, 000FFFFCC44, 00000000000) +000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000) +End of stack trace diff --git a/src/pages/PlacesOfWorshipPage.tsx b/src/pages/PlacesOfWorshipPage.tsx index 73f8ec2..feea77c 100644 --- a/src/pages/PlacesOfWorshipPage.tsx +++ b/src/pages/PlacesOfWorshipPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { trpc } from "@/providers/trpc"; +import { useAuth } from "@/hooks/useAuth"; import { LocationPicker } from "@/components/LocationPicker"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; diff --git a/src/pages/RecipientsPage.tsx b/src/pages/RecipientsPage.tsx index e72810a..79ee8b3 100644 --- a/src/pages/RecipientsPage.tsx +++ b/src/pages/RecipientsPage.tsx @@ -1,7 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { trpc } from "@/providers/trpc"; +import { useAuth } from "@/hooks/useAuth"; import { LocationPicker } from "@/components/LocationPicker"; import { DocumentUploadField } from "@/components/DocumentUploadField"; +import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -139,6 +141,10 @@ export default function RecipientsPage() { utils.dashboard.stats.invalidate(); setIsAddOpen(false); resetForm(); + toast.success("Penerima bantuan baru berhasil didaftarkan!"); + }, + onError: (err) => { + toast.error(err.message || "Gagal mendaftarkan penerima bantuan baru."); }, }); @@ -147,6 +153,10 @@ export default function RecipientsPage() { utils.recipient.list.invalidate(); setIsEditOpen(false); resetForm(); + toast.success("Data penerima bantuan berhasil diperbarui!"); + }, + onError: (err) => { + toast.error(err.message || "Gagal memperbarui data penerima."); }, }); @@ -154,6 +164,10 @@ export default function RecipientsPage() { onSuccess: () => { utils.recipient.list.invalidate(); utils.dashboard.stats.invalidate(); + toast.success("Penerima bantuan berhasil dihapus."); + }, + onError: (err) => { + toast.error(err.message || "Gagal menghapus data penerima."); }, }); @@ -181,8 +195,32 @@ export default function RecipientsPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; - if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return; - if (!formData.ktpDocument || !formData.kkDocument) return; + + if (!formData.nik.trim()) { + toast.error("NIK harus diisi."); + return; + } + if (formData.nik.length !== 16) { + toast.error("NIK harus berjumlah tepat 16 digit."); + return; + } + if (!formData.name.trim()) { + toast.error("Nama Lengkap harus diisi."); + return; + } + if (!resolvedPlaceOfWorshipId) { + toast.error("Gagal menentukan Rumah Ibadah terdekat. Harap klik peta untuk memplot koordinat lokasi."); + return; + } + if (!formData.ktpDocument) { + toast.error("Foto KTP wajib diunggah."); + return; + } + if (!formData.kkDocument) { + toast.error("Foto Kartu Keluarga (KK) wajib diunggah."); + return; + } + createMutation.mutate({ nik: formData.nik, name: formData.name, @@ -228,6 +266,24 @@ export default function RecipientsPage() { e.preventDefault(); const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId; if (!selectedRecipient) return; + + if (!formData.nik.trim()) { + toast.error("NIK harus diisi."); + return; + } + if (formData.nik.length !== 16) { + toast.error("NIK harus berjumlah tepat 16 digit."); + return; + } + if (!formData.name.trim()) { + toast.error("Nama Lengkap harus diisi."); + return; + } + if (!resolvedPlaceOfWorshipId) { + toast.error("Gagal menentukan Rumah Ibadah terdekat. Harap pilih koordinat lokasi di peta."); + return; + } + updateMutation.mutate({ id: selectedRecipient, nik: formData.nik, @@ -264,7 +320,7 @@ export default function RecipientsPage() { setFormData((current) => ({ ...current, ...next })); }; - const nearestPlaceForForm = useMemo(() => { + const nearestPlaceForForm = (() => { const latitude = Number.parseFloat(formData.latitude); const longitude = Number.parseFloat(formData.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { @@ -279,16 +335,7 @@ export default function RecipientsPage() { } } 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)); @@ -329,21 +376,21 @@ export default function RecipientsPage() {
- setFormData({ ...formData, nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" /> + updateFormData({ nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" />
- setFormData({ ...formData, name: e.target.value })} placeholder="Nama" className="h-9" /> + updateFormData({ name: e.target.value })} placeholder="Nama" className="h-9" />
- setFormData({ ...formData, birthDate: e.target.value })} className="h-9" /> + updateFormData({ birthDate: e.target.value })} className="h-9" />
- updateFormData({ gender: v })}> Laki-laki @@ -392,7 +439,7 @@ export default function RecipientsPage() { updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
- + updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
@@ -562,21 +609,21 @@ export default function RecipientsPage() {
- setFormData({ ...formData, nik: e.target.value })} className="h-9" /> + updateFormData({ nik: e.target.value })} className="h-9" />
- setFormData({ ...formData, name: e.target.value })} className="h-9" /> + updateFormData({ name: e.target.value })} className="h-9" />
- setFormData({ ...formData, birthDate: e.target.value })} className="h-9" /> + updateFormData({ birthDate: e.target.value })} className="h-9" />
- updateFormData({ gender: v })}> Laki-laki @@ -625,7 +672,7 @@ export default function RecipientsPage() { updateFormData({ phone: e.target.value })} className="h-9" />
- + updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
@@ -656,7 +703,7 @@ export default function RecipientsPage() { {/* View Dialog */} - + Detail Penerima {selectedRec && (
diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 39194c2..9f9251e 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -1,8 +1,43 @@ +import { useState, useMemo } from "react"; import { useAuth } from "@/hooks/useAuth"; +import { trpc } from "@/providers/trpc"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { Shield, User, UserCog, Crown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Shield, + User, + UserCog, + Crown, + Plus, + Edit, + Trash2, + Loader2, + Search, + KeyRound, + Mail, + UserCircle, +} from "lucide-react"; const ROLE_ICONS: Record = { admin: Crown, @@ -11,9 +46,9 @@ const ROLE_ICONS: Record = { }; const ROLE_COLORS: Record = { - admin: "bg-violet-100 text-violet-700", - officer: "bg-blue-100 text-blue-700", - manager: "bg-slate-100 text-slate-700", + admin: "bg-violet-100 text-violet-700 hover:bg-violet-200", + officer: "bg-blue-100 text-blue-700 hover:bg-blue-200", + manager: "bg-slate-100 text-slate-700 hover:bg-slate-200", }; const ROLE_LABELS: Record = { @@ -24,15 +59,158 @@ const ROLE_LABELS: Record = { export default function UsersPage() { const { user: currentUser } = useAuth(); + const [searchTerm, setSearchTerm] = useState(""); + + // Dialog States + const [isFormOpen, setIsFormOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); // null = Add New + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + + // Form States + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState<"admin" | "officer" | "manager">("manager"); + const [errorMsg, setErrorMsg] = useState(""); + + // tRPC Queries & Mutations + const { data: users, isLoading, refetch } = trpc.user.list.useQuery(undefined, { + enabled: !!currentUser && currentUser.role === "admin", + }); + + const createUserMutation = trpc.user.create.useMutation({ + onSuccess: () => { + toast.success("Pengguna baru berhasil ditambahkan!"); + setIsFormOpen(false); + refetch(); + resetForm(); + }, + onError: (err) => { + setErrorMsg(err.message || "Gagal menambahkan pengguna."); + }, + }); + + const updateUserMutation = trpc.user.update.useMutation({ + onSuccess: () => { + toast.success("Data pengguna berhasil diperbarui!"); + setIsFormOpen(false); + refetch(); + resetForm(); + }, + onError: (err) => { + setErrorMsg(err.message || "Gagal memperbarui data pengguna."); + }, + }); + + const deleteUserMutation = trpc.user.delete.useMutation({ + onSuccess: () => { + toast.success("Pengguna berhasil dihapus."); + setIsDeleteOpen(false); + setUserToDelete(null); + refetch(); + }, + onError: (err) => { + toast.error(err.message || "Gagal menghapus pengguna."); + setIsDeleteOpen(false); + }, + }); + + const resetForm = () => { + setName(""); + setEmail(""); + setPassword(""); + setRole("manager"); + setErrorMsg(""); + setSelectedUser(null); + }; + + const handleOpenAdd = () => { + resetForm(); + setIsFormOpen(true); + }; + + const handleOpenEdit = (user: any) => { + resetForm(); + setSelectedUser(user); + setName(user.name || ""); + setEmail(user.email || ""); + setPassword(""); // Biarkan kosong kecuali ingin merubah sandi + setRole(user.role); + setIsFormOpen(true); + }; + + const handleOpenDelete = (user: any) => { + setUserToDelete(user); + setIsDeleteOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setErrorMsg(""); + + if (!name.trim()) { + setErrorMsg("Nama harus diisi."); + return; + } + + if (!email.trim() || !email.includes("@")) { + setErrorMsg("Email tidak valid."); + return; + } + + if (selectedUser) { + updateUserMutation.mutate({ + id: selectedUser.id, + name, + email, + password: password ? password : undefined, + role, + }); + } else { + if (!password || password.length < 6) { + setErrorMsg("Sandi harus diisi minimal 6 karakter."); + return; + } + createUserMutation.mutate({ + name, + email, + password, + role, + }); + } + }; + + const handleDeleteConfirm = () => { + if (userToDelete) { + deleteUserMutation.mutate({ id: userToDelete.id }); + } + }; + + const filteredUsers = useMemo(() => { + if (!users) return []; + return users.filter( + (u) => + u.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [users, searchTerm]); + + const isPending = createUserMutation.isPending || updateUserMutation.isPending; - // For now, display current user info. In a full app, we'd have a users.list endpoint return ( -
-
-

Manajemen Pengguna

-

- Kelola pengguna sistem dan peran aksesnya -

+
+
+
+

Manajemen Pengguna

+

+ Kelola pengguna sistem, peran hak akses, dan detail akun petugas/pengelola. +

+
+
{/* Role Info Cards */} @@ -49,9 +227,9 @@ export default function UsersPage() {

{label}

- {role === "admin" && "Akses penuh ke seluruh sistem"} - {role === "officer" && "Verifikasi dan setujui pendaftaran warga"} - {role === "manager" && "Mendaftarkan warga dan mengelola distribusi"} + {role === "admin" && "Akses penuh untuk konfigurasi pengguna, sistem, dan rumah ibadah."} + {role === "officer" && "Verifikasi kelayakan berkas KTP, KK, SKTM penerima bantuan sosial."} + {role === "manager" && "Menginput calon penerima dan mencatat riwayat pembagian bantuan."}

@@ -61,92 +239,163 @@ export default function UsersPage() { })}
- {/* Current User */} - - - - - Informasi Pengguna Saat Ini + {/* Main User List Section */} + + + + + Daftar Pengguna Aktif +
+ + setSearchTerm(e.target.value)} + className="pl-8 h-9 text-xs rounded-xl bg-background border-muted" + /> +
- - {!currentUser ? ( - + + {isLoading ? ( +
+ + + + +
+ ) : filteredUsers.length === 0 ? ( +
+ Tidak ada pengguna yang ditemukan. +
) : ( -
-
- {currentUser.name?.charAt(0).toUpperCase() ?? "U"} -
-
-
-

{currentUser.name ?? "User"}

- - {ROLE_LABELS[currentUser.role ?? "manager"]} - -
-

{currentUser.email ?? "-"}

-

- Login terakhir: {currentUser.lastSignInAt ? new Date(currentUser.lastSignInAt).toLocaleDateString("id-ID") : "-"} -

-
+
+ + + + + + + + + + + + {filteredUsers.map((u) => { + const RoleIcon = ROLE_ICONS[u.role] || User; + const isSelf = !!(currentUser && currentUser.id === u.id); + return ( + + + + + + + + ); + })} + +
NamaEmailPeranTerakhir MasukAksi
+
+ {u.name?.charAt(0).toUpperCase()} +
+
+ {u.name} + {isSelf && ( + + Anda + + )} +
+
{u.email} + + + {ROLE_LABELS[u.role]} + + + {u.lastSignInAt + ? new Date(u.lastSignInAt).toLocaleString("id-ID", { + dateStyle: "medium", + timeStyle: "short", + }) + : "-"} + + + +
)} - {/* Permissions Table */} + {/* Permissions Table (Access Matrix) */} - - - Matriks Izin Akses + + + Matriks Izin Akses Sistem
- - - - - + + + + + {[ - { feature: "Dashboard", admin: true, officer: true, manager: true }, - { feature: "Peta GIS", admin: true, officer: true, manager: true }, - { feature: "Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true }, - { feature: "Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" }, - { feature: "Verifikasi Penerima", admin: true, officer: true, manager: false }, - { feature: "Distribusi Bantuan", admin: true, officer: true, manager: true }, - { feature: "Manajemen Pengguna", admin: true, officer: false, manager: false }, + { feature: "Dashboard Utama", admin: true, officer: true, manager: true }, + { feature: "Visualisasi Peta GIS", admin: true, officer: true, manager: true }, + { feature: "Data Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true }, + { feature: "Data Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" }, + { feature: "Pencatatan Distribusi Bantuan", admin: true, officer: true, manager: true }, + { feature: "Verifikasi Kelayakan Dokumen", admin: true, officer: true, manager: false }, + { feature: "Manajemen Pengguna (CRUD)", admin: true, officer: false, manager: false }, { feature: "Pengaturan Sistem", admin: true, officer: false, manager: false }, ].map((row) => ( - - - + + - -
FiturAdminPetugasPengelola
FiturAdminPetugasPengelola
{row.feature} +
{row.feature} {row.admin === true ? ( - Full + Full ) : ( - )} + {row.officer === true ? ( - Full + Full ) : row.officer === "R" ? ( - Read + Lihat ) : ( - )} + {row.manager === true ? ( - Full + Full ) : row.manager === "R" ? ( - Read + Lihat ) : ( - )} @@ -158,6 +407,130 @@ export default function UsersPage() { + + {/* Form Dialog: Add/Edit User */} + + + + + {selectedUser ? "Ubah Data Pengguna" : "Tambah Pengguna Baru"} + + + {selectedUser + ? "Ubah data akun pengguna di bawah ini. Kosongkan sandi jika tidak ingin dirubah." + : "Masukkan nama, email, sandi, serta hak akses untuk akun pengguna baru."} + + + +
+ {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ + setName(e.target.value)} + className="h-9 text-xs rounded-xl" + /> +
+ +
+ + setEmail(e.target.value)} + className="h-9 text-xs rounded-xl" + /> +
+ +
+ + setPassword(e.target.value)} + className="h-9 text-xs rounded-xl" + /> +
+ +
+ + +
+ + + + + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + + Konfirmasi Penghapusan + + + Apakah Anda yakin ingin menghapus akun {userToDelete?.name} ({userToDelete?.email})? Tindakan ini bersifat permanen dan tidak bisa dibatalkan. + + + + + + + + + + ); }