Merge remote-tracking branch 'github/main'
This commit is contained in:
+46
-15
@@ -112,31 +112,62 @@ 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 {
|
||||
await db.insert(users).values({
|
||||
unionId: LocalAuth.defaultAdminEmail,
|
||||
name: "Admin Lokal",
|
||||
email: LocalAuth.defaultAdminEmail,
|
||||
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
|
||||
role: "admin",
|
||||
lastSignInAt: new Date(),
|
||||
});
|
||||
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",
|
||||
email: LocalAuth.defaultAdminEmail,
|
||||
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { recipientRouter } from "./recipientRouter";
|
||||
import { distributionRouter } from "./distributionRouter";
|
||||
import { dashboardRouter } from "./dashboardRouter";
|
||||
import { seedRouter } from "./seedRouter";
|
||||
import { userRouter } from "./userRouter";
|
||||
import { createRouter, publicQuery } from "./middleware";
|
||||
|
||||
export const appRouter = createRouter({
|
||||
@@ -14,6 +15,7 @@ export const appRouter = createRouter({
|
||||
distribution: distributionRouter,
|
||||
dashboard: dashboardRouter,
|
||||
seed: seedRouter,
|
||||
user: userRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
}),
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
+15
-11
@@ -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="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS Poverty Mapping</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getDb } from "../api/queries/connection";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
async function main() {
|
||||
const db = getDb();
|
||||
try {
|
||||
console.log("Altering recipients table columns to LONGTEXT...");
|
||||
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN ktp_document LONGTEXT`);
|
||||
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN kk_document LONGTEXT`);
|
||||
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN sktm_document LONGTEXT`);
|
||||
console.log("Database altered successfully!");
|
||||
} catch (err) {
|
||||
console.error("ERROR ALTERING TABLE:", err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
Stack trace:
|
||||
Frame Function Args
|
||||
000FFFFBCB0 00210062F57 (00000000002, 00000000002, 00000000000, 000FFFFDE50)
|
||||
00000000000 00210065045 (000FFFFC660, 00000000000, 000000001FC, 00000000000)
|
||||
000FFFFC3C0 0021013A968 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||
00000000041 00210135F9B (00000000000, 00000000000, 00000000000, 00000000000)
|
||||
000FFFFC7E0 002101363A5 (00000000000, 00800000000, 000000000B5, 00100437000)
|
||||
000FFFFC7E0 0021021D538 (00000000000, 00800000030, 00000000000, 00000000000)
|
||||
000FFFFC7E0 00100429F25 (000FFFFCC6B, 000FFFFC818, 0000000000A, 00000000001)
|
||||
000FFFFC850 00100404B11 (00000000005, 000FFFFCAB0, 000FFFFCC40, 00000000000)
|
||||
000FFFFCA40 001004294B0 (000FFFFCC40, 000FFFFCC40, 000FFFFCC44, 00000000000)
|
||||
000FFFFCD30 00210049B91 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||
000FFFFFFF0 00210047716 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||
000FFFFFFF0 002100477C4 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||
End of stack trace
|
||||
@@ -1,5 +1,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,73 +259,81 @@ 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>
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="rounded-xl" size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Tambah Rumah Ibadah
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tipe</Label>
|
||||
<Select value={formData.type} onValueChange={(v: typeof formData.type) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mosque">Masjid</SelectItem>
|
||||
<SelectItem value="church">Gereja</SelectItem>
|
||||
<SelectItem value="temple">Pura</SelectItem>
|
||||
<SelectItem value="vihara">Vihara</SelectItem>
|
||||
<SelectItem value="other">Lainnya</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kapasitas</Label>
|
||||
<Input type="number" min={1} value={formData.capacity} onChange={(e) => setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" />
|
||||
</div>
|
||||
</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" />
|
||||
</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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Radius (meter)</Label>
|
||||
<Input type="number" min={100} max={10000} value={formData.radius} onChange={(e) => setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">No. Kontak</Label>
|
||||
<Input value={formData.contactPhone} onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nama Kontak</Label>
|
||||
<Input value={formData.contactName} onChange={(e) => setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
||||
{canModify && (
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="rounded-xl" size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Tambah Rumah Ibadah
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<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 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">
|
||||
<Label className="text-xs">Tipe</Label>
|
||||
<Select value={formData.type} onValueChange={(v: typeof formData.type) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mosque">Masjid</SelectItem>
|
||||
<SelectItem value="church">Gereja</SelectItem>
|
||||
<SelectItem value="temple">Pura</SelectItem>
|
||||
<SelectItem value="vihara">Vihara</SelectItem>
|
||||
<SelectItem value="other">Lainnya</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kapasitas</Label>
|
||||
<Input type="number" min={1} value={formData.capacity} onChange={(e) => setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" />
|
||||
</div>
|
||||
</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" />
|
||||
</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 })} 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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Radius (meter)</Label>
|
||||
<Input type="number" min={100} max={10000} value={formData.radius} onChange={(e) => setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">No. Kontak</Label>
|
||||
<Input value={formData.contactPhone} onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nama Kontak</Label>
|
||||
<Input value={formData.contactName} onChange={(e) => setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
||||
</Button>
|
||||
</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>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
+386
-149
@@ -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,95 +360,122 @@ export default function RecipientsPage() {
|
||||
Kelola data warga penerima bantuan sosial
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="rounded-xl" size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Tambah Penerima
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Penerima Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Laki-laki</SelectItem>
|
||||
<SelectItem value="female">Perempuan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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>
|
||||
</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>
|
||||
<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" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
||||
{canModify && (
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="rounded-xl" size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Tambah Penerima
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Penerima Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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) => 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) => 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) => 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") => updateFormData({ gender: v })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Laki-laki</SelectItem>
|
||||
<SelectItem value="female">Perempuan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Alamat</Label>
|
||||
<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) => updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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
|
||||
value={formatRupiah(formData.incomePerMonth)}
|
||||
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</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) => updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
|
||||
</div>
|
||||
<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,26 +566,32 @@ export default function RecipientsPage() {
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(r)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Hapus penerima ini?")) {
|
||||
deleteMutation.mutate({ id: r.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(r)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Hapus penerima ini?")) {
|
||||
deleteMutation.mutate({ id: r.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
+497
-65
@@ -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,15 +60,176 @@ 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>
|
||||
<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
|
||||
</p>
|
||||
<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, 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 */}
|
||||
@@ -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>
|
||||
<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"]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{currentUser.email ?? "-"}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Login terakhir: {currentUser.lastSignInAt ? new Date(currentUser.lastSignInAt).toLocaleDateString("id-ID") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*"]
|
||||
Reference in New Issue
Block a user