perbaikan tambah akun dan akses setiap role
This commit is contained in:
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
+5
-1
@@ -1,13 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
|
||||
<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="" />
|
||||
<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>
|
||||
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user