Merge remote-tracking branch 'github/main'

This commit is contained in:
2026-06-11 17:28:15 +07:00
28 changed files with 2345 additions and 381 deletions
+38 -7
View File
@@ -112,15 +112,15 @@ export async function seedLocalUserIfMissing() {
}
// Check existence without selecting `password_hash` to avoid failures on older schemas
try {
const [rows] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
if (Array.isArray(rows) && rows.length > 0) return;
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);
}
// Try to insert using the typed Drizzle insert (preferred). If that fails because the
// `password_hash` column is missing, fall back to a raw INSERT that omits the column.
// 1. Ensure admin account
try {
const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) {
await db.insert(users).values({
unionId: LocalAuth.defaultAdminEmail,
name: "Admin Lokal",
@@ -129,14 +129,45 @@ export async function seedLocalUserIfMissing() {
role: "admin",
lastSignInAt: new Date(),
});
console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`);
}
} catch (err) {
console.warn('[seed] typed insert failed, attempting fallback insert without password_hash', err);
console.warn('[seed] Admin seeding failed, attempting fallback', err);
try {
await db.execute(sql`INSERT INTO users (unionId, name, email, role, lastSignInAt) VALUES (
await db.execute(sql`INSERT IGNORE INTO users (unionId, name, email, role, lastSignInAt) VALUES (
${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()}
)`);
} catch (err2) {
console.error('[seed] fallback insert also failed', err2);
console.error('[seed] Admin fallback failed', err2);
}
}
// 2. Seed default roles for testing
const defaultAccounts = [
{ email: "officer@example.com", name: "Petugas Lapangan", role: "officer" as const },
{ email: "manager@example.com", name: "Pengelola Wilayah", role: "manager" as const },
];
for (const acc of defaultAccounts) {
try {
const res = await db.execute(sql`SELECT id FROM users WHERE unionId = ${acc.email} LIMIT 1`);
const [existing] = res as any;
if (existing && (!Array.isArray(existing) || existing.length > 0)) {
continue;
}
await db.insert(users).values({
unionId: acc.email,
name: acc.name,
email: acc.email,
passwordHash: hashPassword("password123"),
role: acc.role,
lastSignInAt: new Date(),
});
console.log(`[seed] Created ${acc.role} account: ${acc.email}`);
} catch (err) {
console.warn(`[seed] Failed to create ${acc.role} (${acc.email})`, err);
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404));
export default app;
if (env.isProduction) {
if (env.isProduction && typeof process !== "undefined" && process.versions?.node) {
const { serve } = await import("@hono/node-server");
const { serveStaticFiles } = await import("./lib/vite");
serveStaticFiles(app);
+92 -9
View File
@@ -1,24 +1,54 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import { createRouter, authedQuery } from "./middleware";
import {
findAllDistributions,
findDistributionById,
createDistribution,
deleteDistribution,
} from "./queries/distributions";
import { findRecipientById } from "./queries/recipients";
import { createActivityLog } from "./queries/activityLogs";
import { findPlaceIdsByManagerId } from "./queries/placesOfWorship";
import { TRPCError } from "@trpc/server";
async function logActivitySafe(payload: Parameters<typeof createActivityLog>[0]) {
try {
await createActivityLog(payload);
} catch (error) {
console.warn("[activity] failed to write distribution activity", error);
}
}
export const distributionRouter = createRouter({
list: publicQuery
list: authedQuery
.input(
z.object({
placeOfWorshipId: z.number().optional(),
recipientId: z.number().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllDistributions(input?.placeOfWorshipId, input?.recipientId);
.query(async ({ input, ctx }) => {
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (placeIds.length === 0) return [];
if (input?.placeOfWorshipId) {
if (!placeIds.includes(input.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang mengakses data distribusi ini.",
});
}
return findAllDistributions([input.placeOfWorshipId], input?.recipientId);
}
return findAllDistributions(placeIds, input?.recipientId);
}
return findAllDistributions(
input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined,
input?.recipientId
);
}),
create: publicQuery
create: authedQuery
.input(
z.object({
placeOfWorshipId: z.number().min(1),
@@ -29,21 +59,74 @@ export const distributionRouter = createRouter({
quantity: z.number().min(1).default(1),
unit: z.string().default("package"),
notes: z.string().optional(),
handoverPhoto: z.string().min(1),
})
)
.mutation(async ({ input }) => {
return createDistribution({
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (!placeIds.includes(input.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda hanya berwenang mencatat distribusi bantuan untuk rumah ibadah yang Anda kelola.",
});
}
}
const created = await createDistribution({
...input,
amount: input.amount.toString(),
distributedBy: input.distributedBy ?? null,
notes: input.notes ?? null,
handoverPhoto: input.handoverPhoto,
});
if (created) {
const recipient = await findRecipientById(created.recipientId);
await logActivitySafe({
userId: ctx.user.id,
action: "CREATE",
entityType: "distribution",
entityId: created.id,
details: recipient
? `Distribusi ${created.aidType} ke ${recipient.name}`
: `Distribusi ${created.aidType}`,
});
}
return created;
}),
delete: publicQuery
delete: authedQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const target = await findDistributionById(input.id);
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Data tidak ditemukan." });
}
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (!placeIds.includes(target.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang menghapus data distribusi ini.",
});
}
}
await deleteDistribution(input.id);
if (target) {
await logActivitySafe({
userId: ctx.user.id,
action: "DELETE",
entityType: "distribution",
entityId: target.id,
details: `Menghapus distribusi ${target.aidType}`,
});
}
return { success: true };
}),
});
+55 -8
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import { createRouter, publicQuery, adminQuery } from "./middleware";
import {
findAllPlaces,
findPlaceById,
@@ -7,6 +7,15 @@ import {
updatePlace,
deletePlace,
} from "./queries/placesOfWorship";
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 place activity", error);
}
}
export const placeOfWorshipRouter = createRouter({
list: publicQuery
@@ -17,8 +26,9 @@ export const placeOfWorshipRouter = createRouter({
active: z.string().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllPlaces(input?.search, input?.type, input?.active);
.query(async ({ input, ctx }) => {
const managerId = ctx.user?.role === "manager" ? ctx.user.id : undefined;
return findAllPlaces(input?.search, input?.type, input?.active, managerId);
}),
byId: publicQuery
@@ -27,7 +37,7 @@ export const placeOfWorshipRouter = createRouter({
return findPlaceById(input.id);
}),
create: publicQuery
create: adminQuery
.input(
z.object({
name: z.string().min(1),
@@ -43,15 +53,27 @@ export const placeOfWorshipRouter = createRouter({
})
)
.mutation(async ({ input }) => {
return createPlace({
const created = await createPlace({
...input,
latitude: input.latitude.toString(),
longitude: input.longitude.toString(),
managerId: input.managerId ?? null,
});
if (created) {
await logActivitySafe({
userId: input.managerId ?? null,
action: "CREATE",
entityType: "place_of_worship",
entityId: created.id,
details: `Menambahkan ${created.name}`,
});
}
return created;
}),
update: publicQuery
update: adminQuery
.input(
z.object({
id: z.number(),
@@ -70,17 +92,42 @@ export const placeOfWorshipRouter = createRouter({
)
.mutation(async ({ input }) => {
const { id, ...data } = input;
const beforeUpdate = await findPlaceById(id);
const updateData: Record<string, unknown> = { ...data };
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
if (data.longitude !== undefined) updateData.longitude = data.longitude.toString();
if (data.managerId === undefined) delete updateData.managerId;
return updatePlace(id, updateData);
const updated = await updatePlace(id, updateData);
if (updated) {
await logActivitySafe({
userId: data.managerId ?? beforeUpdate?.managerId ?? null,
action: "UPDATE",
entityType: "place_of_worship",
entityId: updated.id,
details: `Memperbarui rumah ibadah ${updated.name}`,
});
}
return updated;
}),
delete: publicQuery
delete: adminQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const target = await findPlaceById(input.id);
await deletePlace(input.id);
if (target) {
await logActivitySafe({
userId: target.managerId ?? null,
action: "DELETE",
entityType: "place_of_worship",
entityId: target.id,
details: `Menghapus rumah ibadah ${target.name}`,
});
}
return { success: true };
}),
});
+13 -3
View File
@@ -1,12 +1,22 @@
import { eq, and, desc, count, sql } from "drizzle-orm";
import { eq, and, desc, count, sql, inArray } from "drizzle-orm";
import { getDb } from "./connection";
import { distributions } from "@db/schema";
import type { InsertDistribution } from "@db/schema";
export async function findAllDistributions(placeOfWorshipId?: number, recipientId?: number) {
export async function findAllDistributions(placeOfWorshipIds?: number | number[], recipientId?: number) {
const db = getDb();
const conditions = [];
if (placeOfWorshipId) conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipId));
if (placeOfWorshipIds !== undefined) {
if (Array.isArray(placeOfWorshipIds)) {
if (placeOfWorshipIds.length > 0) {
conditions.push(inArray(distributions.placeOfWorshipId, placeOfWorshipIds));
} else {
conditions.push(sql`1 = 0`);
}
} else {
conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipIds));
}
}
if (recipientId) conditions.push(eq(distributions.recipientId, recipientId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db.select().from(distributions).where(where).orderBy(desc(distributions.distributionDate));
+19 -1
View File
@@ -3,7 +3,7 @@ import { getDb } from "./connection";
import { placesOfWorship } from "@db/schema";
import type { InsertPlaceOfWorship } from "@db/schema";
export async function findAllPlaces(search?: string, type?: string, active?: string) {
export async function findAllPlaces(search?: string, type?: string, active?: string, managerId?: number) {
const db = getDb();
const conditions = [];
if (search) {
@@ -15,6 +15,9 @@ export async function findAllPlaces(search?: string, type?: string, active?: str
if (active && active !== "all") {
conditions.push(eq(placesOfWorship.isActive, active as "yes" | "no"));
}
if (managerId !== undefined) {
conditions.push(eq(placesOfWorship.managerId, managerId));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db.select().from(placesOfWorship).where(where).orderBy(desc(placesOfWorship.createdAt));
}
@@ -25,6 +28,21 @@ export async function findPlaceById(id: number) {
return rows.at(0) ?? null;
}
export async function findPlaceByManagerId(managerId: number) {
const db = getDb();
const rows = await db.select().from(placesOfWorship).where(eq(placesOfWorship.managerId, managerId)).limit(1);
return rows.at(0) ?? null;
}
export async function findPlaceIdsByManagerId(managerId: number) {
const db = getDb();
const rows = await db
.select({ id: placesOfWorship.id })
.from(placesOfWorship)
.where(eq(placesOfWorship.managerId, managerId));
return rows.map((r) => r.id);
}
export async function createPlace(data: InsertPlaceOfWorship) {
const db = getDb();
const [{ id }] = await db.insert(placesOfWorship).values(data).$returningId();
+23 -7
View File
@@ -1,4 +1,4 @@
import { eq, like, and, count, desc } from "drizzle-orm";
import { eq, like, and, count, desc, inArray, sql } from "drizzle-orm";
import { getDb } from "./connection";
import { recipients } from "@db/schema";
import type { InsertRecipient } from "@db/schema";
@@ -6,7 +6,7 @@ import type { InsertRecipient } from "@db/schema";
export async function findAllRecipients(
search?: string,
status?: string,
placeOfWorshipId?: number
placeOfWorshipIds?: number | number[]
) {
const db = getDb();
const conditions = [];
@@ -16,8 +16,16 @@ export async function findAllRecipients(
if (status && status !== "all") {
conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended"));
}
if (placeOfWorshipId) {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
if (placeOfWorshipIds !== undefined) {
if (Array.isArray(placeOfWorshipIds)) {
if (placeOfWorshipIds.length > 0) {
conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds));
} else {
conditions.push(sql`1 = 0`);
}
} else {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds));
}
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db.select().from(recipients).where(where).orderBy(desc(recipients.createdAt));
@@ -29,11 +37,19 @@ export async function findRecipientById(id: number) {
return rows.at(0) ?? null;
}
export async function findPendingRecipients(placeOfWorshipId?: number) {
export async function findPendingRecipients(placeOfWorshipIds?: number | number[]) {
const db = getDb();
const conditions = [eq(recipients.status, "pending")];
if (placeOfWorshipId) {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
if (placeOfWorshipIds !== undefined) {
if (Array.isArray(placeOfWorshipIds)) {
if (placeOfWorshipIds.length > 0) {
conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds));
} else {
conditions.push(sql`1 = 0`);
}
} else {
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds));
}
}
return db.select().from(recipients)
.where(and(...conditions))
+36
View File
@@ -50,3 +50,39 @@ export async function upsertUser(data: InsertUser) {
.values(values)
.onDuplicateKeyUpdate({ set: updateSet });
}
export async function findAllUsers() {
return getDb()
.select({
id: schema.users.id,
unionId: schema.users.unionId,
name: schema.users.name,
email: schema.users.email,
role: schema.users.role,
avatar: schema.users.avatar,
createdAt: schema.users.createdAt,
updatedAt: schema.users.updatedAt,
lastSignInAt: schema.users.lastSignInAt,
})
.from(schema.users)
.orderBy(schema.users.createdAt);
}
export async function createUser(data: InsertUser) {
const [result] = await getDb().insert(schema.users).values(data);
return result;
}
export async function updateUser(id: number, data: Partial<InsertUser>) {
const result = await getDb()
.update(schema.users)
.set(data)
.where(eq(schema.users.id, id));
return result;
}
export async function deleteUser(id: number) {
await getDb()
.delete(schema.users)
.where(eq(schema.users.id, id));
}
+178 -21
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { createRouter, publicQuery } from "./middleware";
import { createRouter, authedQuery } from "./middleware";
import {
findAllRecipients,
findRecipientById,
@@ -9,9 +9,20 @@ import {
verifyRecipient,
deleteRecipient,
} from "./queries/recipients";
import { createActivityLog } from "./queries/activityLogs";
import { findPlaceIdsByManagerId } from "./queries/placesOfWorship";
import { TRPCError } from "@trpc/server";
async function logActivitySafe(payload: Parameters<typeof createActivityLog>[0]) {
try {
await createActivityLog(payload);
} catch (error) {
console.warn("[activity] failed to write recipient activity", error);
}
}
export const recipientRouter = createRouter({
list: publicQuery
list: authedQuery
.input(
z.object({
search: z.string().optional(),
@@ -19,23 +30,66 @@ export const recipientRouter = createRouter({
placeOfWorshipId: z.number().optional(),
}).optional()
)
.query(async ({ input }) => {
return findAllRecipients(input?.search, input?.status, input?.placeOfWorshipId);
.query(async ({ input, ctx }) => {
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (placeIds.length === 0) return [];
if (input?.placeOfWorshipId) {
if (!placeIds.includes(input.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang mengakses data rumah ibadah ini.",
});
}
return findAllRecipients(input?.search, input?.status, [input.placeOfWorshipId]);
}
return findAllRecipients(input?.search, input?.status, placeIds);
}
return findAllRecipients(
input?.search,
input?.status,
input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined
);
}),
pending: publicQuery
pending: authedQuery
.input(z.object({ placeOfWorshipId: z.number().optional() }).optional())
.query(async ({ input }) => {
return findPendingRecipients(input?.placeOfWorshipId);
.query(async ({ input, ctx }) => {
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (placeIds.length === 0) return [];
if (input?.placeOfWorshipId) {
if (!placeIds.includes(input.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang mengakses data rumah ibadah ini.",
});
}
return findPendingRecipients([input.placeOfWorshipId]);
}
return findPendingRecipients(placeIds);
}
return findPendingRecipients(input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined);
}),
byId: publicQuery
byId: authedQuery
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return findRecipientById(input.id);
.query(async ({ input, ctx }) => {
const recipient = await findRecipientById(input.id);
if (!recipient) return null;
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (!placeIds.includes(recipient.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang mengakses data penerima ini.",
});
}
}
return recipient;
}),
create: publicQuery
create: authedQuery
.input(
z.object({
nik: z.string().length(16),
@@ -51,10 +105,23 @@ export const recipientRouter = createRouter({
placeOfWorshipId: z.number().min(1),
registeredBy: z.number().optional(),
notes: z.string().optional(),
ktpDocument: z.string().min(1),
kkDocument: z.string().min(1),
sktmDocument: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return createRecipient({
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (!placeIds.includes(input.placeOfWorshipId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda hanya berwenang mendaftarkan penerima untuk rumah ibadah yang Anda kelola.",
});
}
}
const created = await createRecipient({
...input,
latitude: input.latitude?.toString() ?? null,
longitude: input.longitude?.toString() ?? null,
@@ -64,11 +131,26 @@ export const recipientRouter = createRouter({
phone: input.phone ?? null,
registeredBy: input.registeredBy ?? null,
notes: input.notes ?? null,
ktpDocument: input.ktpDocument,
kkDocument: input.kkDocument,
sktmDocument: input.sktmDocument ?? null,
status: "pending",
});
if (created) {
await logActivitySafe({
userId: ctx.user.id,
action: "CREATE",
entityType: "recipient",
entityId: created.id,
details: `Mendaftarkan ${created.name}`,
});
}
return created;
}),
update: publicQuery
update: authedQuery
.input(
z.object({
id: z.number(),
@@ -84,17 +166,50 @@ export const recipientRouter = createRouter({
incomePerMonth: z.number().min(0).optional(),
placeOfWorshipId: z.number().optional(),
notes: z.string().optional(),
ktpDocument: z.string().optional(),
kkDocument: z.string().optional(),
sktmDocument: z.string().optional(),
})
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
const beforeUpdate = await findRecipientById(id);
if (!beforeUpdate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." });
}
if (ctx.user.role === "manager") {
const placeIds = await findPlaceIdsByManagerId(ctx.user.id);
if (
!placeIds.includes(beforeUpdate.placeOfWorshipId) ||
(input.placeOfWorshipId && !placeIds.includes(input.placeOfWorshipId))
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Anda tidak berwenang mengubah data penerima ini.",
});
}
}
const updateData: Record<string, unknown> = { ...data };
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
if (data.longitude !== undefined) updateData.longitude = data.longitude.toString();
return updateRecipient(id, updateData);
const updated = await updateRecipient(id, updateData);
if (updated) {
await logActivitySafe({
userId: ctx.user.id,
action: "UPDATE",
entityType: "recipient",
entityId: updated.id,
details: `Memperbarui data ${updated.name}`,
});
}
return updated;
}),
verify: publicQuery
verify: authedQuery
.input(
z.object({
id: z.number(),
@@ -103,14 +218,56 @@ export const recipientRouter = createRouter({
rejectionReason: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason);
.mutation(async ({ input, ctx }) => {
// Verify is only allowed for Admin and Officer
if (ctx.user.role !== "admin" && ctx.user.role !== "officer") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Hanya petugas verifikasi atau administrator yang dapat memverifikasi kelayakan.",
});
}
const verified = await verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason);
if (verified) {
const statusText = input.status === "active" ? "Diterima" : "Ditolak";
await logActivitySafe({
userId: input.verifiedBy,
action: "VERIFY",
entityType: "recipient",
entityId: verified.id,
details: `Memverifikasi ${verified.name} - ${statusText}`,
});
}
return verified;
}),
delete: publicQuery
delete: authedQuery
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const target = await findRecipientById(input.id);
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." });
}
if (ctx.user.role === "manager") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Pengelola rumah ibadah tidak berwenang menghapus data penerima.",
});
}
await deleteRecipient(input.id);
if (target) {
await logActivitySafe({
userId: ctx.user.id,
action: "DELETE",
entityType: "recipient",
entityId: target.id,
details: `Menghapus penerima ${target.name}`,
});
}
return { success: true };
}),
});
+2
View File
@@ -4,6 +4,7 @@ import { recipientRouter } from "./recipientRouter";
import { distributionRouter } from "./distributionRouter";
import { dashboardRouter } from "./dashboardRouter";
import { seedRouter } from "./seedRouter";
import { userRouter } from "./userRouter";
import { createRouter, publicQuery } from "./middleware";
export const appRouter = createRouter({
@@ -14,6 +15,7 @@ export const appRouter = createRouter({
distribution: distributionRouter,
dashboard: dashboardRouter,
seed: seedRouter,
user: userRouter,
});
export type AppRouter = typeof appRouter;
+178
View File
@@ -0,0 +1,178 @@
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";
import { getDb } from "./queries/connection";
import * as schema from "@db/schema";
import { inArray, eq } from "drizzle-orm";
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"]),
placeIds: z.array(z.number()).optional(),
})
)
.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;
// Hubungkan rumah ibadah yang dipilih jika perannya adalah manager
if (input.role === "manager" && input.placeIds && input.placeIds.length > 0) {
const db = getDb();
await db
.update(schema.placesOfWorship)
.set({ managerId: insertedId })
.where(inArray(schema.placesOfWorship.id, input.placeIds));
}
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(),
placeIds: z.array(z.number()).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);
// Kelola asosiasi Rumah Ibadah
const db = getDb();
// Cari role saat ini untuk mengecek apakah bertipe manager
const userRec = await db
.select({ role: schema.users.role })
.from(schema.users)
.where(eq(schema.users.id, input.id))
.limit(1);
const userRole = userRec[0]?.role;
if (userRole === "manager") {
// Hapus penugasan manager dari semua rumah ibadah yang sebelumnya dikelola user ini
await db
.update(schema.placesOfWorship)
.set({ managerId: null })
.where(eq(schema.placesOfWorship.managerId, input.id));
// Hubungkan ke kumpulan rumah ibadah baru jika ada
if (input.placeIds && input.placeIds.length > 0) {
await db
.update(schema.placesOfWorship)
.set({ managerId: input.id })
.where(inArray(schema.placesOfWorship.id, input.placeIds));
}
} else {
// Jika peran berubah dari manager ke peran lain, hapus semua relasi kelolanya
await db
.update(schema.placesOfWorship)
.set({ managerId: null })
.where(eq(schema.placesOfWorship.managerId, input.id));
}
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 };
}),
});
+5
View File
@@ -4,6 +4,7 @@ import {
serial,
varchar,
text,
longtext,
timestamp,
bigint,
int,
@@ -78,6 +79,9 @@ export const recipients = mysqlTable(
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
verifiedAt: timestamp("verified_at"),
notes: text("notes"),
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()),
},
@@ -104,6 +108,7 @@ export const distributions = mysqlTable(
quantity: int("quantity").default(1),
unit: varchar("unit", { length: 50 }).default("package"),
distributionDate: timestamp("distribution_date").defaultNow().notNull(),
handoverPhoto: longtext("handover_photo"),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
+15
View File
@@ -0,0 +1,15 @@
Stack trace:
Frame Function Args
000FFFFBCE0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50)
00000000000 00210065045 (000FFFFC690, 00000000000, 000000001F4, 00000000000)
000FFFFC3F0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000)
00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFC810 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000)
000FFFFC810 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000)
000FFFFC810 00100429F25 (000FFFFCC7B, 000FFFFC848, 00000000007, 00000000001)
000FFFFC880 00100404B11 (00000000009, 002101FEA90, 000FFFFCB80, 00000000000)
000FFFFCA70 001004294B0 (00000000006, 000FFFFCD30, 000FFFFCC50, 000FFFFCC50)
000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000)
End of stack trace
+5 -1
View File
@@ -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>
+18
View File
@@ -0,0 +1,18 @@
import { getDb } from "../api/queries/connection";
import { sql } from "drizzle-orm";
async function main() {
const db = getDb();
try {
console.log("Altering recipients table columns to LONGTEXT...");
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN ktp_document LONGTEXT`);
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN kk_document LONGTEXT`);
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN sktm_document LONGTEXT`);
console.log("Database altered successfully!");
} catch (err) {
console.error("ERROR ALTERING TABLE:", err);
}
process.exit(0);
}
main().catch(console.error);
+16
View File
@@ -0,0 +1,16 @@
import { sql } from "drizzle-orm";
import { getDb } from "../api/queries/connection";
import { users } from "../db/schema";
async function check() {
const db = getDb();
const res = await db.execute(sql`SELECT id FROM users WHERE unionId = 'officer@example.com' LIMIT 1`);
console.log("Raw query result for officer:", res);
const allUsers = await db.select().from(users);
console.log("Current Users in DB:");
console.table(allUsers.map(u => ({ id: u.id, email: u.email, role: u.role })));
process.exit(0);
}
check().catch(console.error);
+10
View File
@@ -0,0 +1,10 @@
import { seedLocalUserIfMissing } from "../api/auth";
async function forceSeed() {
console.log("Starting force seed...");
await seedLocalUserIfMissing();
console.log("Force seed completed.");
process.exit(0);
}
forceSeed().catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
import { getDb } from "../api/queries/connection";
import { sql } from "drizzle-orm";
async function main() {
const db = getDb();
try {
const [result] = await db.execute(sql`DESCRIBE recipients`);
console.log("RECIPIENTS TABLE STRUCTURE:", result);
} catch (err) {
console.error("ERROR DESCRIBING TABLE:", err);
}
process.exit(0);
}
main().catch(console.error);
+11 -5
View File
@@ -11,7 +11,13 @@ import VerificationPage from "./pages/VerificationPage";
import UsersPage from "./pages/UsersPage";
import SettingsPage from "./pages/SettingsPage";
function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) {
function ProtectedRoute({
children,
allowedRoles,
}: {
children: React.ReactNode;
allowedRoles?: string[];
}) {
const { user, isLoading } = useAuth();
if (isLoading) {
@@ -26,7 +32,7 @@ function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; ad
return <Navigate to="/login" replace />;
}
if (adminOnly && user.role !== "admin") {
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/" replace />;
}
@@ -72,7 +78,7 @@ export default function App() {
<Route
path="/verification"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={["admin", "officer"]}>
<VerificationPage />
</ProtectedRoute>
}
@@ -80,7 +86,7 @@ export default function App() {
<Route
path="/users"
element={
<ProtectedRoute adminOnly>
<ProtectedRoute allowedRoles={["admin"]}>
<UsersPage />
</ProtectedRoute>
}
@@ -88,7 +94,7 @@ export default function App() {
<Route
path="/settings"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={["admin"]}>
<SettingsPage />
</ProtectedRoute>
}
+9 -10
View File
@@ -33,9 +33,14 @@ const menuItems = [
{ icon: Map, label: "Peta GIS", path: "/map" },
{ icon: Users, label: "Penerima Bantuan", path: "/recipients" },
{ icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" },
{ icon: ClipboardCheck, label: "Verifikasi", path: "/verification" },
{ icon: Shield, label: "Pengguna", path: "/users", adminOnly: true },
{ icon: Settings, label: "Pengaturan", path: "/settings" },
{
icon: ClipboardCheck,
label: "Verifikasi",
path: "/verification",
roles: ["admin", "officer"],
},
{ icon: Shield, label: "Pengguna", path: "/users", roles: ["admin"] },
{ icon: Settings, label: "Pengaturan", path: "/settings", roles: ["admin"] },
];
export default function AppLayout({ children }: { children: ReactNode }) {
@@ -46,11 +51,10 @@ export default function AppLayout({ children }: { children: ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const isAdmin = user?.role === "admin";
const activeItem = menuItems.find((item) => item.path === location.pathname);
const filteredItems = menuItems.filter(
(item) => !item.adminOnly || isAdmin
(item) => !item.roles || (user?.role && item.roles.includes(user.role))
);
const sidebarWidth = collapsed ? 72 : 260;
@@ -95,11 +99,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
{!collapsed && item.adminOnly && (
<Badge variant="outline" className="ml-auto text-[9px] px-1 py-0 h-4">
Admin
</Badge>
)}
</button>
);
})}
+140
View File
@@ -0,0 +1,140 @@
import { useId, useMemo, type ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { FileUp, FileText, Image as ImageIcon, X } from "lucide-react";
import { toast } from "sonner";
type DocumentUploadFieldProps = {
label: string;
value?: string | null;
onChange?: (value: string | null) => void;
required?: boolean;
helperText?: string;
accept?: string;
className?: string;
};
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(reader.error ?? new Error("Gagal membaca file"));
reader.readAsDataURL(file);
});
}
function getPreviewKind(value?: string | null) {
if (!value) return "none";
if (value.startsWith("data:application/pdf")) return "pdf";
if (value.startsWith("data:image/")) return "image";
return "file";
}
export function DocumentUploadField({
label,
value,
onChange,
required = false,
helperText,
accept = "image/*,application/pdf",
className = "",
}: DocumentUploadFieldProps) {
const inputId = useId();
const previewKind = useMemo(() => getPreviewKind(value), [value]);
const handleSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !onChange) return;
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
if (file.size > MAX_SIZE) {
toast.error("Pastikan gambar tidak lebih besar dari 2mb");
return;
}
const dataUrl = await readFileAsDataUrl(file);
onChange(dataUrl);
};
const canEdit = Boolean(onChange);
return (
<div className={`space-y-2 ${className}`}>
<div className="flex items-center justify-between gap-2">
<Label htmlFor={inputId} className="text-xs font-medium">
{label} {required ? <span className="text-red-500">*</span> : null}
</Label>
{canEdit && value ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => onChange?.(null)}
>
<X className="h-3 w-3 mr-1" />
Hapus
</Button>
) : null}
</div>
{helperText ? <p className="text-[11px] text-muted-foreground">{helperText}</p> : null}
<input
id={inputId}
type="file"
accept={accept}
className="hidden"
onChange={handleSelectFile}
disabled={!canEdit}
/>
<label
htmlFor={inputId}
className={`block cursor-pointer rounded-xl border border-dashed p-4 transition-colors ${
value ? "bg-muted/20 hover:bg-muted/30" : "bg-background hover:bg-muted/20"
} ${!canEdit ? "cursor-default" : ""}`}
>
{value ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{previewKind === "image" ? (
<ImageIcon className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)}
<span>Dokumen siap dipreview</span>
</div>
{previewKind === "image" ? (
<img
src={value}
alt={label}
className="h-40 w-full rounded-lg border object-contain bg-white"
/>
) : previewKind === "pdf" ? (
<iframe
src={value}
title={label}
className="h-40 w-full rounded-lg border bg-white"
/>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border bg-white text-xs text-muted-foreground">
File terunggah
</div>
)}
{canEdit ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileUp className="h-3.5 w-3.5" />
Klik untuk mengganti file
</div>
) : null}
</div>
) : (
<div className="flex min-h-32 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<FileUp className="h-6 w-6" />
<p className="text-sm font-medium">Klik untuk upload atau seret file ke sini</p>
<p className="text-[11px]">{accept.toUpperCase()} {required ? "- Wajib" : "- Opsional"}</p>
</div>
)}
</label>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
import L, { type LatLngExpression, type LeafletEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
type LocationValue = {
latitude: string;
longitude: string;
address: string;
};
type LocationPickerProps = {
value: LocationValue;
onChange: (next: Partial<LocationValue>) => void;
label?: string;
hint?: string;
};
const DEFAULT_CENTER: LatLngExpression = [-6.2088, 106.8456];
const markerIcon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
function parsePosition(value: LocationValue) {
const latitude = Number.parseFloat(value.latitude);
const longitude = Number.parseFloat(value.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return { lat: latitude, lng: longitude };
}
async function reverseGeocode(latitude: number, longitude: number) {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${latitude}&lon=${longitude}&accept-language=id&addressdetails=1`,
{
headers: {
Accept: "application/json",
},
},
);
if (!response.ok) {
throw new Error("Reverse geocode failed");
}
const data = (await response.json()) as { display_name?: string };
return data.display_name ?? "";
}
function LocationMap({
position,
onPick,
}: {
position: { lat: number; lng: number } | null;
onPick: (latitude: number, longitude: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (position) {
map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15), {
animate: true,
});
}
}, [map, position]);
useMapEvents({
click(event) {
onPick(event.latlng.lat, event.latlng.lng);
},
});
if (!position) {
return null;
}
return (
<Marker
position={[position.lat, position.lng]}
icon={markerIcon}
draggable
eventHandlers={{
dragend(event: LeafletEvent) {
const marker = event.target as L.Marker;
const { lat, lng } = marker.getLatLng();
onPick(lat, lng);
},
}}
/>
);
}
export function LocationPicker({ value, onChange, label = "Pilih titik lokasi", hint = "Klik peta atau geser pin untuk mengisi koordinat dan alamat otomatis." }: LocationPickerProps) {
const position = useMemo(() => parsePosition(value), [value]);
const requestRef = useRef(0);
const [isResolving, setIsResolving] = useState(false);
const handlePick = async (latitude: number, longitude: number) => {
const nextLatitude = latitude.toFixed(6);
const nextLongitude = longitude.toFixed(6);
onChange({ latitude: nextLatitude, longitude: nextLongitude });
const requestId = ++requestRef.current;
setIsResolving(true);
try {
const resolvedAddress = await reverseGeocode(latitude, longitude);
if (requestRef.current !== requestId) return;
if (resolvedAddress) {
onChange({ address: resolvedAddress });
}
} catch {
// Keep the selected coordinates even if reverse geocoding fails.
} finally {
if (requestRef.current === requestId) {
setIsResolving(false);
}
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium">{label}</p>
<p className="text-[11px] text-muted-foreground">{hint}</p>
</div>
{isResolving && <p className="text-[11px] text-muted-foreground">Mengambil alamat...</p>}
</div>
<div className="overflow-hidden rounded-xl border bg-muted/20">
<MapContainer
center={position ?? DEFAULT_CENTER}
zoom={position ? 15 : 12}
scrollWheelZoom
className="h-[280px] w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<LocationMap position={position} onPick={handlePick} />
</MapContainer>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
Stack trace:
Frame Function Args
000FFFFBCB0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50)
00000000000 00210065045 (000FFFFC660, 00000000000, 000000001FC, 00000000000)
000FFFFC3C0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000)
00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFC7E0 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000)
000FFFFC7E0 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000)
000FFFFC7E0 00100429F25 (000FFFFCC6B, 000FFFFC818, 0000000000A, 00000000001)
000FFFFC850 00100404B11 (00000000005, 000FFFFCAB0, 000FFFFCC40, 00000000000)
000FFFFCA40 001004294B0 (000FFFFCC40, 000FFFFCC40, 000FFFFCC44, 00000000000)
000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000)
000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000)
End of stack trace
+308 -6
View File
@@ -1,5 +1,9 @@
import { 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";
@@ -44,13 +48,42 @@ const TYPE_LABELS: Record<string, string> = {
other: "Lainnya",
};
function formatRupiah(amount: number) {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0,
}).format(amount);
}
function parseRupiahInput(input: string) {
const digits = input.replace(/\D/g, "");
return digits ? Number.parseInt(digits, 10) : 0;
}
export default function PlacesOfWorshipPage() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const isManager = user?.role === "manager";
const canModify = isAdmin; // Only Admin can modify Places of Worship
const canDistribute = isAdmin || isManager; // Admin and Manager can distribute aid
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isAidOpen, setIsAidOpen] = useState(false);
const [selectedPlace, setSelectedPlace] = useState<number | null>(null);
const [aidForm, setAidForm] = useState({
recipientId: 0,
aidType: "Sembako",
amount: 0,
quantity: 1,
unit: "paket",
notes: "",
handoverPhoto: "",
});
const [formData, setFormData] = useState({
name: "",
type: "mosque" as "mosque" | "church" | "temple" | "vihara" | "other",
@@ -68,6 +101,14 @@ export default function PlacesOfWorshipPage() {
search: search || undefined,
type: typeFilter === "all" ? undefined : typeFilter,
});
const { data: placeRecipients } = trpc.recipient.list.useQuery(
{ placeOfWorshipId: selectedPlace ?? undefined },
{ enabled: isAidOpen && selectedPlace !== null },
);
const { data: placeDistributions } = trpc.distribution.list.useQuery(
{ placeOfWorshipId: selectedPlace ?? undefined },
{ enabled: isAidOpen && selectedPlace !== null },
);
const createMutation = trpc.placeOfWorship.create.useMutation({
onSuccess: () => {
@@ -93,6 +134,22 @@ export default function PlacesOfWorshipPage() {
},
});
const createAidMutation = trpc.distribution.create.useMutation({
onSuccess: () => {
utils.distribution.list.invalidate();
utils.dashboard.stats.invalidate();
setAidForm({
recipientId: 0,
aidType: "Sembako",
amount: 0,
quantity: 1,
unit: "paket",
notes: "",
handoverPhoto: "",
});
},
});
const resetForm = () => {
setFormData({
name: "",
@@ -158,6 +215,42 @@ export default function PlacesOfWorshipPage() {
};
const selectedPlaceData = places?.find((p) => p.id === selectedPlace);
const selectedPlaceRecipients = useMemo(
() => (placeRecipients ?? []).filter((recipient) => recipient.placeOfWorshipId === selectedPlace),
[placeRecipients, selectedPlace],
);
const selectedPlaceDistributions = useMemo(
() => (placeDistributions ?? []).filter((distribution) => distribution.placeOfWorshipId === selectedPlace),
[placeDistributions, selectedPlace],
);
const activeRecipients = selectedPlaceRecipients.filter((recipient) => recipient.status === "active");
const totalAidAmount = selectedPlaceDistributions.reduce(
(sum, distribution) => sum + Number.parseFloat(String(distribution.amount ?? 0)),
0,
);
const updateFormData = (next: Partial<typeof formData>) => {
setFormData((current) => ({ ...current, ...next }));
};
const handleAidSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedPlace || !aidForm.recipientId) return;
if (!aidForm.handoverPhoto) {
toast.error("Foto penyerahan wajib diunggah.");
return;
}
createAidMutation.mutate({
placeOfWorshipId: selectedPlace,
recipientId: aidForm.recipientId,
aidType: aidForm.aidType,
amount: aidForm.amount,
quantity: aidForm.quantity,
unit: aidForm.unit,
notes: aidForm.notes || undefined,
handoverPhoto: aidForm.handoverPhoto,
});
};
return (
<div className="space-y-4">
@@ -166,6 +259,7 @@ export default function PlacesOfWorshipPage() {
<h2 className="text-2xl font-bold tracking-tight">Rumah Ibadah</h2>
<p className="text-muted-foreground mt-1 text-sm">Kelola data rumah ibadah dan cakupan wilayahnya</p>
</div>
{canModify && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button className="rounded-xl" size="sm">
@@ -177,8 +271,8 @@ export default function PlacesOfWorshipPage() {
<DialogHeader><DialogTitle>Tambah Rumah Ibadah</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Nama</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Nama rumah ibadah" className="h-9" />
<Label className="text-xs">Nama Tempat Ibadah</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
@@ -203,6 +297,12 @@ export default function PlacesOfWorshipPage() {
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih lokasi rumah ibadah"
hint="Klik peta atau geser pin. Koordinat dan alamat akan terisi otomatis."
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Latitude</Label>
@@ -233,6 +333,7 @@ export default function PlacesOfWorshipPage() {
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Filters */}
@@ -298,16 +399,25 @@ export default function PlacesOfWorshipPage() {
<span>{place.contactPhone}</span>
</div>
)}
<div className="flex items-center gap-1 pt-2">
<div className="flex items-center gap-1 pt-2 flex-wrap">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setSelectedPlace(place.id); setIsViewOpen(true); }}>
<Eye className="h-3.5 w-3.5" />
</Button>
{canModify && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canDistribute && (
<Button variant="secondary" size="sm" className="h-7 px-2 text-xs rounded-lg" onClick={() => { setSelectedPlace(place.id); setIsAidOpen(true); }}>
Bantuan
</Button>
)}
{canModify && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => { if (confirm("Hapus rumah ibadah ini?")) deleteMutation.mutate({ id: place.id }); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</CardContent>
@@ -316,14 +426,200 @@ export default function PlacesOfWorshipPage() {
</div>
)}
{/* Aid Dialog */}
<Dialog open={isAidOpen} onOpenChange={setIsAidOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Manajemen Bantuan {selectedPlaceData ? `- ${selectedPlaceData.name}` : ""}</DialogTitle>
</DialogHeader>
{selectedPlaceData ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Card className="rounded-xl border bg-muted/20">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total Penerima</p>
<p className="text-2xl font-bold">{selectedPlaceRecipients.length}</p>
</CardContent>
</Card>
<Card className="rounded-xl border bg-muted/20">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Penerima Aktif</p>
<p className="text-2xl font-bold">{activeRecipients.length}</p>
</CardContent>
</Card>
<Card className="rounded-xl border bg-muted/20">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total Bantuan Tersalurkan</p>
<p className="text-2xl font-bold">{formatRupiah(totalAidAmount)}</p>
</CardContent>
</Card>
</div>
<form onSubmit={handleAidSubmit} className="space-y-4 rounded-2xl border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-sm">Pemberian Bantuan Baru</h3>
<p className="text-xs text-muted-foreground">Pilih penerima aktif yang terdaftar di rumah ibadah ini</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Penerima</Label>
<Select
value={String(aidForm.recipientId)}
onValueChange={(value) => setAidForm((current) => ({ ...current, recipientId: parseInt(value) || 0 }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Pilih penerima aktif" />
</SelectTrigger>
<SelectContent>
{activeRecipients.map((recipient) => (
<SelectItem key={recipient.id} value={String(recipient.id)}>
{recipient.name} - {recipient.phone || "-"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Jenis Bantuan</Label>
<Input
value={aidForm.aidType}
onChange={(e) => setAidForm((current) => ({ ...current, aidType: e.target.value }))}
className="h-9"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Jumlah (Rp)</Label>
<Input
value={formatRupiah(aidForm.amount)}
onChange={(e) => setAidForm((current) => ({ ...current, amount: parseRupiahInput(e.target.value) }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Qty</Label>
<Input
type="number"
min={1}
value={aidForm.quantity}
onChange={(e) => setAidForm((current) => ({ ...current, quantity: parseInt(e.target.value) || 1 }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Satuan</Label>
<Input
value={aidForm.unit}
onChange={(e) => setAidForm((current) => ({ ...current, unit: e.target.value }))}
className="h-9"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Catatan</Label>
<Input
value={aidForm.notes}
onChange={(e) => setAidForm((current) => ({ ...current, notes: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<DocumentUploadField
label="Foto Penyerahan Bantuan"
value={aidForm.handoverPhoto}
onChange={(value) => setAidForm((current) => ({ ...current, handoverPhoto: value ?? "" }))}
required={true}
helperText="Wajib menampakkan wajah penerima bantuan saat penyerahan"
accept="image/*"
/>
</div>
<Button type="submit" className="w-full" disabled={createAidMutation.isPending || !aidForm.recipientId || !aidForm.handoverPhoto}>
{createAidMutation.isPending ? "Menyimpan bantuan..." : "Simpan Bantuan"}
</Button>
</form>
<div className="space-y-3">
<h3 className="font-semibold text-sm">History Pemberian Bantuan</h3>
{selectedPlaceDistributions.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground rounded-2xl border">
Belum ada history bantuan di rumah ibadah ini
</div>
) : (
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Tanggal</th>
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Penerima</th>
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Jenis</th>
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Jumlah</th>
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Catatan</th>
<th className="text-left py-3 px-3 text-xs font-medium text-muted-foreground">Foto Bukti</th>
</tr>
</thead>
<tbody>
{selectedPlaceDistributions.map((distribution) => {
const recipientName = selectedPlaceRecipients.find((recipient) => recipient.id === distribution.recipientId)?.name ?? "-";
return (
<tr key={distribution.id} className="border-b last:border-0">
<td className="py-3 px-3 text-xs text-muted-foreground whitespace-nowrap">
{new Date(distribution.distributionDate).toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td className="py-3 px-3 text-xs">{recipientName}</td>
<td className="py-3 px-3 text-xs">{distribution.aidType}</td>
<td className="py-3 px-3 text-xs">{formatRupiah(Number.parseFloat(String(distribution.amount ?? 0)))}</td>
<td className="py-3 px-3 text-xs text-muted-foreground">{distribution.notes ?? "-"}</td>
<td className="py-3 px-3 text-xs">
{distribution.handoverPhoto ? (
<Dialog>
<DialogTrigger asChild>
<img
src={distribution.handoverPhoto}
alt="Bukti Penyerahan"
className="h-8 w-12 object-cover rounded cursor-pointer border hover:opacity-80 transition-opacity"
/>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Foto Penyerahan Bantuan</DialogTitle>
</DialogHeader>
<img
src={distribution.handoverPhoto}
alt="Bukti Penyerahan"
className="w-full max-h-[70vh] object-contain rounded-lg border bg-white"
/>
</DialogContent>
</Dialog>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Edit Rumah Ibadah</DialogTitle></DialogHeader>
<form onSubmit={handleUpdate} className="space-y-4">
<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" />
<Label className="text-xs">Nama Tempat Ibadah</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
@@ -348,6 +644,12 @@ export default function PlacesOfWorshipPage() {
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih lokasi rumah ibadah"
hint="Klik peta atau geser pin. Koordinat dan alamat akan terisi otomatis."
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"><Label className="text-xs">Latitude</Label><Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs">Longitude</Label><Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} className="h-9" /></div>
+309 -72
View File
@@ -1,5 +1,9 @@
import { 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";
@@ -43,7 +47,44 @@ const STATUS_OPTIONS = [
{ value: "suspended", label: "Ditangguhkan" },
];
function haversineDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number) {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const earthRadiusKm = 6371;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
}
function formatDistance(distanceKm: number) {
if (distanceKm < 1) {
return `${Math.round(distanceKm * 1000)} m`;
}
return `${distanceKm.toFixed(2)} km`;
}
function formatRupiah(amount: number) {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0,
}).format(amount);
}
function parseRupiahInput(input: string) {
const digits = input.replace(/\D/g, "");
return digits ? Number.parseInt(digits, 10) : 0;
}
export default function RecipientsPage() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const isManager = user?.role === "manager";
const canModify = isAdmin || isManager;
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -57,12 +98,15 @@ export default function RecipientsPage() {
gender: "male" as "male" | "female",
address: "",
phone: "",
familyMembers: 1,
familyMembers: 1 as number | "",
incomePerMonth: 0,
placeOfWorshipId: 1,
placeOfWorshipId: 0,
notes: "",
latitude: "",
longitude: "",
ktpDocument: "",
kkDocument: "",
sktmDocument: "",
});
const utils = trpc.useUtils();
@@ -73,12 +117,34 @@ export default function RecipientsPage() {
const { data: places } = trpc.placeOfWorship.list.useQuery({});
const placesWithCoordinates = useMemo(() => {
return (places ?? [])
.map((place) => {
const latitude = Number.parseFloat(String(place.latitude));
const longitude = Number.parseFloat(String(place.longitude));
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
id: place.id,
name: place.name,
latitude,
longitude,
};
})
.filter((place): place is NonNullable<typeof place> => place !== null);
}, [places]);
const createMutation = trpc.recipient.create.useMutation({
onSuccess: () => {
utils.recipient.list.invalidate();
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.");
},
});
@@ -87,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.");
},
});
@@ -94,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.");
},
});
@@ -107,17 +181,46 @@ export default function RecipientsPage() {
phone: "",
familyMembers: 1,
incomePerMonth: 0,
placeOfWorshipId: 1,
placeOfWorshipId: 0,
notes: "",
latitude: "",
longitude: "",
ktpDocument: "",
kkDocument: "",
sktmDocument: "",
});
setSelectedRecipient(null);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.nik || !formData.name) return;
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
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,
@@ -125,12 +228,15 @@ export default function RecipientsPage() {
gender: formData.gender,
address: formData.address || undefined,
phone: formData.phone || undefined,
familyMembers: formData.familyMembers,
familyMembers: Number(formData.familyMembers) || 1,
incomePerMonth: formData.incomePerMonth,
placeOfWorshipId: formData.placeOfWorshipId,
placeOfWorshipId: resolvedPlaceOfWorshipId,
notes: formData.notes || undefined,
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
ktpDocument: formData.ktpDocument,
kkDocument: formData.kkDocument,
sktmDocument: formData.sktmDocument || undefined,
});
};
@@ -148,6 +254,9 @@ export default function RecipientsPage() {
notes: recipient.notes ?? "",
latitude: recipient.latitude?.toString() ?? "",
longitude: recipient.longitude?.toString() ?? "",
ktpDocument: recipient.ktpDocument ?? "",
kkDocument: recipient.kkDocument ?? "",
sktmDocument: recipient.sktmDocument ?? "",
});
setSelectedRecipient(recipient.id);
setIsEditOpen(true);
@@ -155,7 +264,26 @@ export default function RecipientsPage() {
const handleUpdate = (e: React.FormEvent) => {
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,
@@ -164,12 +292,15 @@ export default function RecipientsPage() {
gender: formData.gender,
address: formData.address || undefined,
phone: formData.phone || undefined,
familyMembers: formData.familyMembers,
familyMembers: Number(formData.familyMembers) || 1,
incomePerMonth: formData.incomePerMonth,
placeOfWorshipId: formData.placeOfWorshipId,
placeOfWorshipId: resolvedPlaceOfWorshipId,
notes: formData.notes || undefined,
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
ktpDocument: formData.ktpDocument || undefined,
kkDocument: formData.kkDocument || undefined,
sktmDocument: formData.sktmDocument || undefined,
});
};
@@ -185,6 +316,41 @@ export default function RecipientsPage() {
const selectedRec = recipients?.find((r) => r.id === selectedRecipient);
const updateFormData = (next: Partial<typeof formData>) => {
setFormData((current) => ({ ...current, ...next }));
};
const nearestPlaceForForm = (() => {
const latitude = Number.parseFloat(formData.latitude);
const longitude = Number.parseFloat(formData.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
let nearest: { id: number; name: string; distanceKm: number } | null = null;
for (const place of placesWithCoordinates) {
const distanceKm = haversineDistanceKm(latitude, longitude, place.latitude, place.longitude);
if (!nearest || distanceKm < nearest.distanceKm) {
nearest = { id: place.id, name: place.name, distanceKm };
}
}
return nearest;
})();
const getRecipientDistanceToPlace = (recipient: NonNullable<typeof recipients>[number]) => {
const latitude = Number.parseFloat(String(recipient.latitude));
const longitude = Number.parseFloat(String(recipient.longitude));
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const place = placesWithCoordinates.find((item) => item.id === recipient.placeOfWorshipId);
if (!place) {
return null;
}
return haversineDistanceKm(latitude, longitude, place.latitude, place.longitude);
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -194,6 +360,7 @@ export default function RecipientsPage() {
Kelola data warga penerima bantuan sosial
</p>
</div>
{canModify && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button className="rounded-xl" size="sm">
@@ -209,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>
@@ -234,55 +401,81 @@ export default function RecipientsPage() {
</div>
<div className="space-y-1.5">
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
<Input value={formData.address} onChange={(e) => updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih titik lokasi dari peta"
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
/>
<div className="grid grid-cols-1 gap-3">
<DocumentUploadField
label="Foto KTP (Kartu Tanda Penduduk)"
value={formData.ktpDocument}
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
required
helperText="JPG, PNG, PDF - Maks 2MB"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<DocumentUploadField
label="Foto Kartu Keluarga (KK)"
value={formData.kkDocument}
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
required
helperText="JPG, PNG, PDF"
/>
<DocumentUploadField
label="Surat Keterangan Tidak Mampu (SKTM)"
value={formData.sktmDocument}
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
helperText="JPG, PNG, PDF (Opsional)"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Telepon</Label>
<Input value={formData.phone} onChange={(e) => setFormData({ ...formData, 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 className="space-y-1.5">
<Label className="text-xs">Jumlah Keluarga</Label>
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => setFormData({ ...formData, familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
<Label className="text-xs">Jumlah Anggota Keluarga</Label>
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: e.target.value === "" ? "" : parseInt(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">Pendapatan/Bulan (Rp)</Label>
<Input type="number" min={0} value={formData.incomePerMonth} onChange={(e) => setFormData({ ...formData, incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rumah Ibadah</Label>
<Select value={String(formData.placeOfWorshipId)} onValueChange={(v) => setFormData({ ...formData, placeOfWorshipId: parseInt(v) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(places ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={formatRupiah(formData.incomePerMonth)}
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(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">Latitude</Label>
<Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Longitude</Label>
<Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" />
</div>
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
<p>
{nearestPlaceForForm
? `${nearestPlaceForForm.name}${formatDistance(nearestPlaceForForm.distanceKm)}`
: "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Catatan</Label>
<Input value={formData.notes} onChange={(e) => setFormData({ ...formData, notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
<Input value={formData.notes} onChange={(e) => updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
</div>
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
<Button
type="submit"
className="w-full"
disabled={createMutation.isPending || !formData.ktpDocument || !formData.kkDocument}
>
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
</Button>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Filters */}
@@ -338,6 +531,7 @@ export default function RecipientsPage() {
<TableHead className="text-xs">Nama</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs">Rumah Ibadah</TableHead>
<TableHead className="text-xs">Jarak</TableHead>
<TableHead className="text-xs">Keluarga</TableHead>
<TableHead className="text-xs">Pendapatan</TableHead>
<TableHead className="text-xs">Aksi</TableHead>
@@ -352,9 +546,15 @@ export default function RecipientsPage() {
<TableCell className="text-xs">
{places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"}
</TableCell>
<TableCell className="text-xs">
{(() => {
const distanceKm = getRecipientDistanceToPlace(r);
return distanceKm === null ? "-" : formatDistance(distanceKm);
})()}
</TableCell>
<TableCell className="text-xs">{r.familyMembers}</TableCell>
<TableCell className="text-xs">
Rp {(r.incomePerMonth ?? 0).toLocaleString("id-ID")}
{formatRupiah(r.incomePerMonth ?? 0)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
@@ -366,6 +566,8 @@ export default function RecipientsPage() {
>
<Eye className="h-3.5 w-3.5" />
</Button>
{canModify && (
<>
<Button
variant="ghost"
size="icon"
@@ -374,6 +576,7 @@ export default function RecipientsPage() {
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{isAdmin && (
<Button
variant="ghost"
size="icon"
@@ -386,6 +589,9 @@ export default function RecipientsPage() {
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
@@ -405,21 +611,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>
@@ -430,44 +636,65 @@ export default function RecipientsPage() {
</div>
<div className="space-y-1.5">
<Label className="text-xs">Alamat</Label>
<Input value={formData.address} onChange={(e) => setFormData({ ...formData, address: e.target.value })} className="h-9" />
<Input value={formData.address} onChange={(e) => updateFormData({ address: e.target.value })} className="h-9" />
</div>
<LocationPicker
value={formData}
onChange={updateFormData}
label="Pilih titik lokasi dari peta"
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
/>
<div className="grid grid-cols-1 gap-3">
<DocumentUploadField
label="Foto KTP (Kartu Tanda Penduduk)"
value={formData.ktpDocument}
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
required
helperText="JPG, PNG, PDF - Maks 2MB"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<DocumentUploadField
label="Foto Kartu Keluarga (KK)"
value={formData.kkDocument}
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
required
helperText="JPG, PNG, PDF"
/>
<DocumentUploadField
label="Surat Keterangan Tidak Mampu (SKTM)"
value={formData.sktmDocument}
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
helperText="JPG, PNG, PDF (Opsional)"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Telepon</Label>
<Input value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} className="h-9" />
<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>
<Input type="number" value={formData.familyMembers} onChange={(e) => setFormData({ ...formData, familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
<Label className="text-xs">Jumlah Anggota Keluarga</Label>
<Input type="number" min={1} value={formData.familyMembers} onChange={(e) => updateFormData({ familyMembers: e.target.value === "" ? "" : parseInt(e.target.value) })} className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Pendapatan/Bulan</Label>
<Input type="number" value={formData.incomePerMonth} onChange={(e) => setFormData({ ...formData, incomePerMonth: parseInt(e.target.value) || 0 })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rumah Ibadah</Label>
<Select value={String(formData.placeOfWorshipId)} onValueChange={(v) => setFormData({ ...formData, placeOfWorshipId: parseInt(v) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(places ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={formatRupiah(formData.incomePerMonth)}
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(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">Latitude</Label>
<Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Longitude</Label>
<Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} className="h-9" />
</div>
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
<p>
{nearestPlaceForForm
? `${nearestPlaceForForm.name}${formatDistance(nearestPlaceForForm.distanceKm)}`
: "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
</p>
</div>
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Memperbarui..." : "Perbarui"}
@@ -478,7 +705,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">
@@ -506,6 +733,16 @@ export default function RecipientsPage() {
{selectedRec.notes && (
<div className="text-xs bg-muted p-2 rounded-lg"><span className="text-muted-foreground">Catatan:</span> {selectedRec.notes}</div>
)}
{(selectedRec.ktpDocument || selectedRec.kkDocument || selectedRec.sktmDocument) && (
<div className="space-y-2">
<p className="text-xs font-medium">Dokumen Terunggah</p>
<div className="grid grid-cols-1 gap-3">
<DocumentUploadField label="KTP" value={selectedRec.ktpDocument} />
<DocumentUploadField label="KK" value={selectedRec.kkDocument} />
<DocumentUploadField label="SKTM" value={selectedRec.sktmDocument} />
</div>
</div>
)}
</div>
)}
</DialogContent>
+490 -58
View File
@@ -1,8 +1,44 @@
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 { Checkbox } from "@/components/ui/checkbox";
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 +47,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 +60,177 @@ 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 [selectedPlaces, setSelectedPlaces] = useState<number[]>([]);
const [errorMsg, setErrorMsg] = useState("");
// tRPC Queries & Mutations
const { data: users, isLoading, refetch } = trpc.user.list.useQuery(undefined, {
enabled: !!currentUser && currentUser.role === "admin",
});
const { data: places, isLoading: placesLoading } = trpc.placeOfWorship.list.useQuery();
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");
setSelectedPlaces([]);
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);
// Ambil list ID rumah ibadah yang sedang dikelola oleh pengguna ini
const managed = (places ?? [])
.filter((p) => p.managerId === user.id)
.map((p) => p.id);
setSelectedPlaces(managed);
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 (role === "manager" && selectedPlaces.length === 0) {
setErrorMsg("Harap pilih minimal satu rumah ibadah yang dikelola.");
return;
}
if (selectedUser) {
updateUserMutation.mutate({
id: selectedUser.id,
name,
email,
password: password ? password : undefined,
role,
placeIds: role === "manager" ? selectedPlaces : undefined,
});
} else {
if (!password || password.length < 6) {
setErrorMsg("Sandi harus diisi minimal 6 karakter.");
return;
}
createUserMutation.mutate({
name,
email,
password,
role,
placeIds: role === "manager" ? selectedPlaces : undefined,
});
}
};
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 +246,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 +258,172 @@ 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>
{u.role === "manager" && (
<div className="text-[10px] text-muted-foreground mt-1 max-w-xs truncate">
Mengelola:{" "}
{(places ?? [])
.filter((p) => p.managerId === u.id)
.map((p) => p.name)
.join(", ") || "-"}
</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 +435,161 @@ 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>
{role === "manager" && (
<div className="space-y-1.5">
<Label className="text-xs font-medium">Rumah Ibadah yang Dikelola (Bisa lebih dari satu)</Label>
<div className="border rounded-xl p-3 max-h-40 overflow-y-auto space-y-2 bg-background">
{placesLoading ? (
<div className="space-y-1 text-xs text-muted-foreground">Memuat data...</div>
) : !places || places.length === 0 ? (
<div className="text-xs text-muted-foreground">Belum ada data rumah ibadah.</div>
) : (
places.map((place) => (
<label key={place.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 p-1 rounded-lg transition-colors">
<Checkbox
checked={selectedPlaces.includes(place.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPlaces([...selectedPlaces, place.id]);
} else {
setSelectedPlaces(selectedPlaces.filter((id) => id !== place.id));
}
}}
/>
<span>
{place.name} <span className="text-[10px] text-muted-foreground capitalize">({place.type})</span>
</span>
</label>
))
)}
</div>
</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>
);
}
+11
View File
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { DocumentUploadField } from "@/components/DocumentUploadField";
import { useAuth } from "@/hooks/useAuth";
import {
ClipboardCheck,
@@ -183,6 +184,16 @@ export default function VerificationPage() {
{r.notes && (
<p className="text-xs bg-muted p-2 rounded-lg">{r.notes}</p>
)}
{(r.ktpDocument || r.kkDocument || r.sktmDocument) && (
<div className="space-y-2 pt-2">
<p className="text-[11px] font-medium text-muted-foreground">Preview Dokumen</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<DocumentUploadField label="KTP" value={r.ktpDocument} />
<DocumentUploadField label="KK" value={r.kkDocument} />
<DocumentUploadField label="SKTM" value={r.sktmDocument} />
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
+9
View File
@@ -0,0 +1,9 @@
name = "webgispemetaankemiskinan"
main = "api/boot.ts"
compatibility_date = "2024-03-01"
compatibility_flags = [ "nodejs_compat" ]
[assets]
directory = "./dist/public"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*"]