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
|
// Check existence without selecting `password_hash` to avoid failures on older schemas
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.warn('[seed] existence check failed, will attempt to insert anyway', 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)
|
.values(values)
|
||||||
.onDuplicateKeyUpdate({ set: updateSet });
|
.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 { distributionRouter } from "./distributionRouter";
|
||||||
import { dashboardRouter } from "./dashboardRouter";
|
import { dashboardRouter } from "./dashboardRouter";
|
||||||
import { seedRouter } from "./seedRouter";
|
import { seedRouter } from "./seedRouter";
|
||||||
|
import { userRouter } from "./userRouter";
|
||||||
import { createRouter, publicQuery } from "./middleware";
|
import { createRouter, publicQuery } from "./middleware";
|
||||||
|
|
||||||
export const appRouter = createRouter({
|
export const appRouter = createRouter({
|
||||||
@@ -14,6 +15,7 @@ export const appRouter = createRouter({
|
|||||||
distribution: distributionRouter,
|
distribution: distributionRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
seed: seedRouter,
|
seed: seedRouter,
|
||||||
|
user: userRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
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,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
text,
|
||||||
|
longtext,
|
||||||
timestamp,
|
timestamp,
|
||||||
bigint,
|
bigint,
|
||||||
int,
|
int,
|
||||||
@@ -78,9 +79,9 @@ export const recipients = mysqlTable(
|
|||||||
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
||||||
verifiedAt: timestamp("verified_at"),
|
verifiedAt: timestamp("verified_at"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
ktpDocument: text("ktp_document"),
|
ktpDocument: longtext("ktp_document"),
|
||||||
kkDocument: text("kk_document"),
|
kkDocument: longtext("kk_document"),
|
||||||
sktmDocument: text("sktm_document"),
|
sktmDocument: longtext("sktm_document"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
|
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
|
||||||
+14
-10
@@ -1,13 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="id">
|
<html lang="id">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta charset="UTF-8" />
|
||||||
<title>WebGIS Poverty Mapping</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
<title>WebGIS Poverty Mapping</title>
|
||||||
</head>
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
<body>
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||||
<div id="root"></div>
|
</head>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</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 { useMemo, useState } from "react";
|
||||||
import { trpc } from "@/providers/trpc";
|
import { trpc } from "@/providers/trpc";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { LocationPicker } from "@/components/LocationPicker";
|
import { LocationPicker } from "@/components/LocationPicker";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { trpc } from "@/providers/trpc";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { LocationPicker } from "@/components/LocationPicker";
|
import { LocationPicker } from "@/components/LocationPicker";
|
||||||
import { DocumentUploadField } from "@/components/DocumentUploadField";
|
import { DocumentUploadField } from "@/components/DocumentUploadField";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -139,6 +141,10 @@ export default function RecipientsPage() {
|
|||||||
utils.dashboard.stats.invalidate();
|
utils.dashboard.stats.invalidate();
|
||||||
setIsAddOpen(false);
|
setIsAddOpen(false);
|
||||||
resetForm();
|
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();
|
utils.recipient.list.invalidate();
|
||||||
setIsEditOpen(false);
|
setIsEditOpen(false);
|
||||||
resetForm();
|
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: () => {
|
onSuccess: () => {
|
||||||
utils.recipient.list.invalidate();
|
utils.recipient.list.invalidate();
|
||||||
utils.dashboard.stats.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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
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({
|
createMutation.mutate({
|
||||||
nik: formData.nik,
|
nik: formData.nik,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -228,6 +266,24 @@ export default function RecipientsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
||||||
if (!selectedRecipient) return;
|
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({
|
updateMutation.mutate({
|
||||||
id: selectedRecipient,
|
id: selectedRecipient,
|
||||||
nik: formData.nik,
|
nik: formData.nik,
|
||||||
@@ -264,7 +320,7 @@ export default function RecipientsPage() {
|
|||||||
setFormData((current) => ({ ...current, ...next }));
|
setFormData((current) => ({ ...current, ...next }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const nearestPlaceForForm = useMemo(() => {
|
const nearestPlaceForForm = (() => {
|
||||||
const latitude = Number.parseFloat(formData.latitude);
|
const latitude = Number.parseFloat(formData.latitude);
|
||||||
const longitude = Number.parseFloat(formData.longitude);
|
const longitude = Number.parseFloat(formData.longitude);
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
@@ -279,16 +335,7 @@ export default function RecipientsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nearest;
|
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 getRecipientDistanceToPlace = (recipient: NonNullable<typeof recipients>[number]) => {
|
||||||
const latitude = Number.parseFloat(String(recipient.latitude));
|
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="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">NIK</Label>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Nama Lengkap</Label>
|
<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>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Tanggal Lahir</Label>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Jenis Kelamin</Label>
|
<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>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="male">Laki-laki</SelectItem>
|
<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" />
|
<Input value={formData.phone} onChange={(e) => updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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" />
|
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -562,21 +609,21 @@ export default function RecipientsPage() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">NIK</Label>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Nama</Label>
|
<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>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Tanggal Lahir</Label>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Jenis Kelamin</Label>
|
<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>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="male">Laki-laki</SelectItem>
|
<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" />
|
<Input value={formData.phone} onChange={(e) => updateFormData({ phone: e.target.value })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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" />
|
<Input type="number" value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -656,7 +703,7 @@ export default function RecipientsPage() {
|
|||||||
|
|
||||||
{/* View Dialog */}
|
{/* View Dialog */}
|
||||||
<Dialog open={isViewOpen} onOpenChange={setIsViewOpen}>
|
<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>
|
<DialogHeader><DialogTitle>Detail Penerima</DialogTitle></DialogHeader>
|
||||||
{selectedRec && (
|
{selectedRec && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
|
|||||||
+438
-65
@@ -1,8 +1,43 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { trpc } from "@/providers/trpc";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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> = {
|
const ROLE_ICONS: Record<string, typeof Shield> = {
|
||||||
admin: Crown,
|
admin: Crown,
|
||||||
@@ -11,9 +46,9 @@ const ROLE_ICONS: Record<string, typeof Shield> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
admin: "bg-violet-100 text-violet-700",
|
admin: "bg-violet-100 text-violet-700 hover:bg-violet-200",
|
||||||
officer: "bg-blue-100 text-blue-700",
|
officer: "bg-blue-100 text-blue-700 hover:bg-blue-200",
|
||||||
manager: "bg-slate-100 text-slate-700",
|
manager: "bg-slate-100 text-slate-700 hover:bg-slate-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -24,15 +59,158 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { user: currentUser } = useAuth();
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Pengguna</h2>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<h2 className="text-2xl font-bold tracking-tight">Manajemen Pengguna</h2>
|
||||||
Kelola pengguna sistem dan peran aksesnya
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Role Info Cards */}
|
{/* Role Info Cards */}
|
||||||
@@ -49,9 +227,9 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm">{label}</h3>
|
<h3 className="font-semibold text-sm">{label}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{role === "admin" && "Akses penuh ke seluruh sistem"}
|
{role === "admin" && "Akses penuh untuk konfigurasi pengguna, sistem, dan rumah ibadah."}
|
||||||
{role === "officer" && "Verifikasi dan setujui pendaftaran warga"}
|
{role === "officer" && "Verifikasi kelayakan berkas KTP, KK, SKTM penerima bantuan sosial."}
|
||||||
{role === "manager" && "Mendaftarkan warga dan mengelola distribusi"}
|
{role === "manager" && "Menginput calon penerima dan mencatat riwayat pembagian bantuan."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,92 +239,163 @@ export default function UsersPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current User */}
|
{/* Main User List Section */}
|
||||||
<Card className="rounded-2xl shadow-sm border-0">
|
<Card className="rounded-2xl shadow-sm border-0 overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
<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-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
<User className="h-4 w-4 text-primary" />
|
||||||
Informasi Pengguna Saat Ini
|
Daftar Pengguna Aktif
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
{!currentUser ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<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="overflow-x-auto">
|
||||||
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-bold">
|
<table className="w-full text-sm">
|
||||||
{currentUser.name?.charAt(0).toUpperCase() ?? "U"}
|
<thead>
|
||||||
</div>
|
<tr className="border-b bg-muted/5 text-left text-xs font-semibold text-muted-foreground">
|
||||||
<div className="flex-1">
|
<th className="py-3 px-4">Nama</th>
|
||||||
<div className="flex items-center gap-2">
|
<th className="py-3 px-4">Email</th>
|
||||||
<h3 className="font-semibold">{currentUser.name ?? "User"}</h3>
|
<th className="py-3 px-4">Peran</th>
|
||||||
<Badge className={`text-[10px] capitalize ${ROLE_COLORS[currentUser.role ?? "manager"]}`}>
|
<th className="py-3 px-4">Terakhir Masuk</th>
|
||||||
{ROLE_LABELS[currentUser.role ?? "manager"]}
|
<th className="py-3 px-4 text-right">Aksi</th>
|
||||||
</Badge>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<p className="text-sm text-muted-foreground">{currentUser.email ?? "-"}</p>
|
<tbody>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
{filteredUsers.map((u) => {
|
||||||
Login terakhir: {currentUser.lastSignInAt ? new Date(currentUser.lastSignInAt).toLocaleDateString("id-ID") : "-"}
|
const RoleIcon = ROLE_ICONS[u.role] || User;
|
||||||
</p>
|
const isSelf = !!(currentUser && currentUser.id === u.id);
|
||||||
</div>
|
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>
|
||||||
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Permissions Table */}
|
{/* Permissions Table (Access Matrix) */}
|
||||||
<Card className="rounded-2xl shadow-sm border-0">
|
<Card className="rounded-2xl shadow-sm border-0">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
Matriks Izin Akses
|
Matriks Izin Akses Sistem
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b">
|
<tr className="border-b text-left text-xs font-semibold text-muted-foreground">
|
||||||
<th className="text-left py-3 px-3 font-medium text-muted-foreground text-xs">Fitur</th>
|
<th className="py-3 px-3">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">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">Petugas</th>
|
||||||
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Pengelola</th>
|
<th className="text-center py-3 px-3">Pengelola</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ feature: "Dashboard", admin: true, officer: true, manager: true },
|
{ feature: "Dashboard Utama", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Peta GIS", admin: true, officer: true, manager: true },
|
{ feature: "Visualisasi Peta GIS", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true },
|
{ feature: "Data Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true },
|
||||||
{ feature: "Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" },
|
{ feature: "Data Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" },
|
||||||
{ feature: "Verifikasi Penerima", admin: true, officer: true, manager: false },
|
{ feature: "Pencatatan Distribusi Bantuan", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Distribusi Bantuan", admin: true, officer: true, manager: true },
|
{ feature: "Verifikasi Kelayakan Dokumen", admin: true, officer: true, manager: false },
|
||||||
{ feature: "Manajemen Pengguna", admin: true, officer: false, manager: false },
|
{ feature: "Manajemen Pengguna (CRUD)", admin: true, officer: false, manager: false },
|
||||||
{ feature: "Pengaturan Sistem", admin: true, officer: false, manager: false },
|
{ feature: "Pengaturan Sistem", admin: true, officer: false, manager: false },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/30">
|
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/5 transition-colors">
|
||||||
<td className="py-3 px-3 text-xs font-medium">{row.feature}</td>
|
<td className="py-2.5 px-3 text-xs font-medium">{row.feature}</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.admin === true ? (
|
{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>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.officer === true ? (
|
{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" ? (
|
) : 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>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.manager === true ? (
|
{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" ? (
|
) : 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>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
@@ -158,6 +407,130 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user