perbaikan tambah akun dan akses setiap role

This commit is contained in:
2026-06-03 09:30:40 +07:00
parent a904a27934
commit 1ee12d8a72
13 changed files with 764 additions and 105 deletions
+1 -1
View File
@@ -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);
}
+36
View File
@@ -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<InsertUser>) {
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));
}
+2
View File
@@ -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;
+132
View File
@@ -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<typeof createActivityLog>[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<string, any> = {};
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 };
}),
});
+4 -3
View File
@@ -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()),
},
+15
View File
@@ -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
+9 -5
View File
@@ -1,13 +1,17 @@
<!doctype html>
<html lang="id">
<head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGIS Poverty Mapping</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
</head>
<body>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</body>
</html>
+18
View File
@@ -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);
+15
View File
@@ -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);
+15
View File
@@ -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
+1
View File
@@ -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";
+72 -25
View File
@@ -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<typeof recipients>[number]) => {
const latitude = Number.parseFloat(String(recipient.latitude));
@@ -329,21 +376,21 @@ export default function RecipientsPage() {
<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" />
<Input value={formData.nik} onChange={(e) => updateFormData({ 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" />
<Input value={formData.name} onChange={(e) => updateFormData({ 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" />
<Input type="date" value={formData.birthDate} onChange={(e) => updateFormData({ 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 })}>
<Select value={formData.gender} onValueChange={(v: "male" | "female") => updateFormData({ gender: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Laki-laki</SelectItem>
@@ -392,7 +439,7 @@ export default function RecipientsPage() {
<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>
<Label className="text-xs">Jumlah Anggota Keluarga</Label>
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
</div>
</div>
@@ -562,21 +609,21 @@ export default function RecipientsPage() {
<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 })} className="h-9" />
<Input value={formData.nik} onChange={(e) => updateFormData({ nik: e.target.value })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Nama</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="h-9" />
<Input value={formData.name} onChange={(e) => updateFormData({ name: e.target.value })} 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" />
<Input type="date" value={formData.birthDate} onChange={(e) => updateFormData({ 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 })}>
<Select value={formData.gender} onValueChange={(v: "male" | "female") => updateFormData({ gender: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Laki-laki</SelectItem>
@@ -625,7 +672,7 @@ export default function RecipientsPage() {
<Input value={formData.phone} onChange={(e) => updateFormData({ phone: e.target.value })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Jumlah Keluarga</Label>
<Label className="text-xs">Jumlah Anggota Keluarga</Label>
<Input type="number" value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
</div>
</div>
@@ -656,7 +703,7 @@ export default function RecipientsPage() {
{/* View Dialog */}
<Dialog open={isViewOpen} onOpenChange={setIsViewOpen}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Detail Penerima</DialogTitle></DialogHeader>
{selectedRec && (
<div className="space-y-3 text-sm">
+432 -59
View File
@@ -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<string, typeof Shield> = {
admin: Crown,
@@ -11,9 +46,9 @@ const ROLE_ICONS: Record<string, typeof Shield> = {
};
const ROLE_COLORS: Record<string, string> = {
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<string, string> = {
@@ -24,16 +59,159 @@ const ROLE_LABELS: Record<string, string> = {
export default function UsersPage() {
const { user: currentUser } = useAuth();
const [searchTerm, setSearchTerm] = useState("");
// Dialog States
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<any>(null); // null = Add New
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<any>(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 (
<div className="space-y-4">
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight">Manajemen Pengguna</h2>
<p className="text-muted-foreground mt-1 text-sm">
Kelola pengguna sistem dan peran aksesnya
Kelola pengguna sistem, peran hak akses, dan detail akun petugas/pengelola.
</p>
</div>
<Button onClick={handleOpenAdd} className="h-9 text-xs rounded-xl flex items-center gap-1.5 self-start sm:self-auto">
<Plus className="h-4 w-4" />
Tambah Pengguna
</Button>
</div>
{/* Role Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -49,9 +227,9 @@ export default function UsersPage() {
<div>
<h3 className="font-semibold text-sm">{label}</h3>
<p className="text-xs text-muted-foreground mt-1">
{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."}
</p>
</div>
</div>
@@ -61,92 +239,163 @@ export default function UsersPage() {
})}
</div>
{/* Current User */}
<Card className="rounded-2xl shadow-sm border-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
Informasi Pengguna Saat Ini
{/* Main User List Section */}
<Card className="rounded-2xl shadow-sm border-0 overflow-hidden">
<CardHeader className="pb-3 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-muted/10">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<User className="h-4 w-4 text-primary" />
Daftar Pengguna Aktif
</CardTitle>
<div className="relative w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari nama atau email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8 h-9 text-xs rounded-xl bg-background border-muted"
/>
</div>
</CardHeader>
<CardContent>
{!currentUser ? (
<Skeleton className="h-20 w-full" />
<CardContent className="p-0">
{isLoading ? (
<div className="p-6 space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : filteredUsers.length === 0 ? (
<div className="p-12 text-center text-muted-foreground text-sm">
Tidak ada pengguna yang ditemukan.
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-4 p-3 bg-muted/30 rounded-xl">
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-bold">
{currentUser.name?.charAt(0).toUpperCase() ?? "U"}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/5 text-left text-xs font-semibold text-muted-foreground">
<th className="py-3 px-4">Nama</th>
<th className="py-3 px-4">Email</th>
<th className="py-3 px-4">Peran</th>
<th className="py-3 px-4">Terakhir Masuk</th>
<th className="py-3 px-4 text-right">Aksi</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => {
const RoleIcon = ROLE_ICONS[u.role] || User;
const isSelf = !!(currentUser && currentUser.id === u.id);
return (
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/10 transition-colors">
<td className="py-3 px-4 font-medium flex items-center gap-2">
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold">
{u.name?.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{currentUser.name ?? "User"}</h3>
<Badge className={`text-[10px] capitalize ${ROLE_COLORS[currentUser.role ?? "manager"]}`}>
{ROLE_LABELS[currentUser.role ?? "manager"]}
<div>
<span>{u.name}</span>
{isSelf && (
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-semibold">
Anda
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-muted-foreground">{u.email}</td>
<td className="py-3 px-4">
<Badge variant="outline" className={`text-[10px] gap-1 rounded-lg border-0 font-medium ${ROLE_COLORS[u.role]}`}>
<RoleIcon className="h-3 w-3" />
{ROLE_LABELS[u.role]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{currentUser.email ?? "-"}</p>
<p className="text-xs text-muted-foreground mt-1">
Login terakhir: {currentUser.lastSignInAt ? new Date(currentUser.lastSignInAt).toLocaleDateString("id-ID") : "-"}
</p>
</div>
</td>
<td className="py-3 px-4 text-muted-foreground text-xs">
{u.lastSignInAt
? new Date(u.lastSignInAt).toLocaleString("id-ID", {
dateStyle: "medium",
timeStyle: "short",
})
: "-"}
</td>
<td className="py-3 px-4 text-right space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenEdit(u)}
className="h-8 w-8 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg"
>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={isSelf}
onClick={() => handleOpenDelete(u)}
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg disabled:opacity-30"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Permissions Table */}
{/* Permissions Table (Access Matrix) */}
<Card className="rounded-2xl shadow-sm border-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
Matriks Izin Akses
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
Matriks Izin Akses Sistem
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-3 font-medium text-muted-foreground text-xs">Fitur</th>
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Admin</th>
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Petugas</th>
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Pengelola</th>
<tr className="border-b text-left text-xs font-semibold text-muted-foreground">
<th className="py-3 px-3">Fitur</th>
<th className="text-center py-3 px-3">Admin</th>
<th className="text-center py-3 px-3">Petugas</th>
<th className="text-center py-3 px-3">Pengelola</th>
</tr>
</thead>
<tbody>
{[
{ 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) => (
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-3 px-3 text-xs font-medium">{row.feature}</td>
<td className="py-3 px-3 text-center">
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/5 transition-colors">
<td className="py-2.5 px-3 text-xs font-medium">{row.feature}</td>
<td className="py-2.5 px-3 text-center">
{row.admin === true ? (
<span className="text-emerald-600 text-xs">Full</span>
<span className="text-emerald-600 font-semibold text-xs">Full</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</td>
<td className="py-3 px-3 text-center">
<td className="py-2.5 px-3 text-center">
{row.officer === true ? (
<span className="text-emerald-600 text-xs">Full</span>
<span className="text-emerald-600 font-semibold text-xs">Full</span>
) : row.officer === "R" ? (
<span className="text-blue-600 text-xs">Read</span>
<span className="text-blue-600 font-semibold text-xs">Lihat</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</td>
<td className="py-3 px-3 text-center">
<td className="py-2.5 px-3 text-center">
{row.manager === true ? (
<span className="text-emerald-600 text-xs">Full</span>
<span className="text-emerald-600 font-semibold text-xs">Full</span>
) : row.manager === "R" ? (
<span className="text-blue-600 text-xs">Read</span>
<span className="text-blue-600 font-semibold text-xs">Lihat</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
@@ -158,6 +407,130 @@ export default function UsersPage() {
</div>
</CardContent>
</Card>
{/* Form Dialog: Add/Edit User */}
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{selectedUser ? "Ubah Data Pengguna" : "Tambah Pengguna Baru"}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{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."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
{errorMsg && (
<div className="p-3 text-xs rounded-xl bg-red-50 text-red-600 font-medium">
{errorMsg}
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-1.5">
<UserCircle className="h-3.5 w-3.5 text-muted-foreground" />
Nama Lengkap
</Label>
<Input
placeholder="cth: Bapak Sulaiman"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 text-xs rounded-xl"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
Alamat Email
</Label>
<Input
type="email"
placeholder="cth: user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-9 text-xs rounded-xl"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
Kata Sandi
</Label>
<Input
type="password"
placeholder={selectedUser ? "•••••• (kosongkan jika tidak ingin dirubah)" : "Min. 6 karakter"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-9 text-xs rounded-xl"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
Peran Hak Akses
</Label>
<Select value={role} onValueChange={(val: any) => setRole(val)}>
<SelectTrigger className="h-9 text-xs rounded-xl">
<SelectValue placeholder="Pilih Peran" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="admin" className="text-xs">Administrator</SelectItem>
<SelectItem value="officer" className="text-xs">Petugas Verifikasi (Officer)</SelectItem>
<SelectItem value="manager" className="text-xs">Pengelola Rumah Ibadah (Manager)</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter className="pt-3 gap-2">
<DialogClose asChild>
<Button type="button" variant="outline" className="h-9 text-xs rounded-xl">
Batal
</Button>
</DialogClose>
<Button type="submit" disabled={isPending} className="h-9 text-xs rounded-xl flex items-center gap-1.5">
{isPending && <Loader2 className="h-3 w-3 animate-spin" />}
{selectedUser ? "Simpan Perubahan" : "Buat Akun"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm font-bold text-red-600">
Konfirmasi Penghapusan
</DialogTitle>
<DialogDescription className="text-xs">
Apakah Anda yakin ingin menghapus akun <strong>{userToDelete?.name}</strong> ({userToDelete?.email})? Tindakan ini bersifat permanen dan tidak bisa dibatalkan.
</DialogDescription>
</DialogHeader>
<DialogFooter className="pt-2 gap-2 sm:justify-end">
<DialogClose asChild>
<Button variant="outline" className="h-9 text-xs rounded-xl">
Batal
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteUserMutation.isPending}
className="h-9 text-xs rounded-xl flex items-center gap-1.5"
>
{deleteUserMutation.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
Hapus Akun
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}