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
|
// Check existence without selecting `password_hash` to avoid failures on older schemas
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
|
await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
|
||||||
if (Array.isArray(rows) && rows.length > 0) return;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[seed] existence check failed, will attempt to insert anyway', err);
|
console.warn('[seed] existence check failed, will attempt to insert anyway', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to insert using the typed Drizzle insert (preferred). If that fails because the
|
// 1. Ensure admin account
|
||||||
// `password_hash` column is missing, fall back to a raw INSERT that omits the column.
|
|
||||||
try {
|
try {
|
||||||
await db.insert(users).values({
|
const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
|
||||||
unionId: LocalAuth.defaultAdminEmail,
|
if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) {
|
||||||
name: "Admin Lokal",
|
await db.insert(users).values({
|
||||||
email: LocalAuth.defaultAdminEmail,
|
unionId: LocalAuth.defaultAdminEmail,
|
||||||
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
|
name: "Admin Lokal",
|
||||||
role: "admin",
|
email: LocalAuth.defaultAdminEmail,
|
||||||
lastSignInAt: new Date(),
|
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
|
||||||
});
|
role: "admin",
|
||||||
|
lastSignInAt: new Date(),
|
||||||
|
});
|
||||||
|
console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} 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 {
|
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()}
|
${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()}
|
||||||
)`);
|
)`);
|
||||||
} catch (err2) {
|
} 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;
|
export default app;
|
||||||
|
|
||||||
if (env.isProduction) {
|
if (env.isProduction && typeof process !== "undefined" && process.versions?.node) {
|
||||||
const { serve } = await import("@hono/node-server");
|
const { serve } = await import("@hono/node-server");
|
||||||
const { serveStaticFiles } = await import("./lib/vite");
|
const { serveStaticFiles } = await import("./lib/vite");
|
||||||
serveStaticFiles(app);
|
serveStaticFiles(app);
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createRouter, publicQuery } from "./middleware";
|
import { createRouter, authedQuery } from "./middleware";
|
||||||
import {
|
import {
|
||||||
findAllDistributions,
|
findAllDistributions,
|
||||||
|
findDistributionById,
|
||||||
createDistribution,
|
createDistribution,
|
||||||
deleteDistribution,
|
deleteDistribution,
|
||||||
} from "./queries/distributions";
|
} 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({
|
export const distributionRouter = createRouter({
|
||||||
list: publicQuery
|
list: authedQuery
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
placeOfWorshipId: z.number().optional(),
|
placeOfWorshipId: z.number().optional(),
|
||||||
recipientId: z.number().optional(),
|
recipientId: z.number().optional(),
|
||||||
}).optional()
|
}).optional()
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return findAllDistributions(input?.placeOfWorshipId, input?.recipientId);
|
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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
placeOfWorshipId: z.number().min(1),
|
placeOfWorshipId: z.number().min(1),
|
||||||
@@ -29,21 +59,74 @@ export const distributionRouter = createRouter({
|
|||||||
quantity: z.number().min(1).default(1),
|
quantity: z.number().min(1).default(1),
|
||||||
unit: z.string().default("package"),
|
unit: z.string().default("package"),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
handoverPhoto: z.string().min(1),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
return createDistribution({
|
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,
|
...input,
|
||||||
amount: input.amount.toString(),
|
amount: input.amount.toString(),
|
||||||
distributedBy: input.distributedBy ?? null,
|
distributedBy: input.distributedBy ?? null,
|
||||||
notes: input.notes ?? 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() }))
|
.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);
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createRouter, publicQuery } from "./middleware";
|
import { createRouter, publicQuery, adminQuery } from "./middleware";
|
||||||
import {
|
import {
|
||||||
findAllPlaces,
|
findAllPlaces,
|
||||||
findPlaceById,
|
findPlaceById,
|
||||||
@@ -7,6 +7,15 @@ import {
|
|||||||
updatePlace,
|
updatePlace,
|
||||||
deletePlace,
|
deletePlace,
|
||||||
} from "./queries/placesOfWorship";
|
} 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({
|
export const placeOfWorshipRouter = createRouter({
|
||||||
list: publicQuery
|
list: publicQuery
|
||||||
@@ -17,8 +26,9 @@ export const placeOfWorshipRouter = createRouter({
|
|||||||
active: z.string().optional(),
|
active: z.string().optional(),
|
||||||
}).optional()
|
}).optional()
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return findAllPlaces(input?.search, input?.type, input?.active);
|
const managerId = ctx.user?.role === "manager" ? ctx.user.id : undefined;
|
||||||
|
return findAllPlaces(input?.search, input?.type, input?.active, managerId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: publicQuery
|
byId: publicQuery
|
||||||
@@ -27,7 +37,7 @@ export const placeOfWorshipRouter = createRouter({
|
|||||||
return findPlaceById(input.id);
|
return findPlaceById(input.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: publicQuery
|
create: adminQuery
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -43,15 +53,27 @@ export const placeOfWorshipRouter = createRouter({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return createPlace({
|
const created = await createPlace({
|
||||||
...input,
|
...input,
|
||||||
latitude: input.latitude.toString(),
|
latitude: input.latitude.toString(),
|
||||||
longitude: input.longitude.toString(),
|
longitude: input.longitude.toString(),
|
||||||
managerId: input.managerId ?? null,
|
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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -70,17 +92,42 @@ export const placeOfWorshipRouter = createRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { id, ...data } = input;
|
const { id, ...data } = input;
|
||||||
|
const beforeUpdate = await findPlaceById(id);
|
||||||
const updateData: Record<string, unknown> = { ...data };
|
const updateData: Record<string, unknown> = { ...data };
|
||||||
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
|
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
|
||||||
if (data.longitude !== undefined) updateData.longitude = data.longitude.toString();
|
if (data.longitude !== undefined) updateData.longitude = data.longitude.toString();
|
||||||
if (data.managerId === undefined) delete updateData.managerId;
|
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() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
const target = await findPlaceById(input.id);
|
||||||
await deletePlace(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 };
|
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 { getDb } from "./connection";
|
||||||
import { distributions } from "@db/schema";
|
import { distributions } from "@db/schema";
|
||||||
import type { InsertDistribution } 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 db = getDb();
|
||||||
const conditions = [];
|
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));
|
if (recipientId) conditions.push(eq(distributions.recipientId, recipientId));
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
return db.select().from(distributions).where(where).orderBy(desc(distributions.distributionDate));
|
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 { placesOfWorship } from "@db/schema";
|
||||||
import type { InsertPlaceOfWorship } 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 db = getDb();
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -15,6 +15,9 @@ export async function findAllPlaces(search?: string, type?: string, active?: str
|
|||||||
if (active && active !== "all") {
|
if (active && active !== "all") {
|
||||||
conditions.push(eq(placesOfWorship.isActive, active as "yes" | "no"));
|
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;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
return db.select().from(placesOfWorship).where(where).orderBy(desc(placesOfWorship.createdAt));
|
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;
|
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) {
|
export async function createPlace(data: InsertPlaceOfWorship) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [{ id }] = await db.insert(placesOfWorship).values(data).$returningId();
|
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 { getDb } from "./connection";
|
||||||
import { recipients } from "@db/schema";
|
import { recipients } from "@db/schema";
|
||||||
import type { InsertRecipient } from "@db/schema";
|
import type { InsertRecipient } from "@db/schema";
|
||||||
@@ -6,7 +6,7 @@ import type { InsertRecipient } from "@db/schema";
|
|||||||
export async function findAllRecipients(
|
export async function findAllRecipients(
|
||||||
search?: string,
|
search?: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
placeOfWorshipId?: number
|
placeOfWorshipIds?: number | number[]
|
||||||
) {
|
) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
@@ -16,8 +16,16 @@ export async function findAllRecipients(
|
|||||||
if (status && status !== "all") {
|
if (status && status !== "all") {
|
||||||
conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended"));
|
conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended"));
|
||||||
}
|
}
|
||||||
if (placeOfWorshipId) {
|
if (placeOfWorshipIds !== undefined) {
|
||||||
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
|
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;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
return db.select().from(recipients).where(where).orderBy(desc(recipients.createdAt));
|
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;
|
return rows.at(0) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findPendingRecipients(placeOfWorshipId?: number) {
|
export async function findPendingRecipients(placeOfWorshipIds?: number | number[]) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const conditions = [eq(recipients.status, "pending")];
|
const conditions = [eq(recipients.status, "pending")];
|
||||||
if (placeOfWorshipId) {
|
if (placeOfWorshipIds !== undefined) {
|
||||||
conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId));
|
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)
|
return db.select().from(recipients)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
|
|||||||
@@ -50,3 +50,39 @@ export async function upsertUser(data: InsertUser) {
|
|||||||
.values(values)
|
.values(values)
|
||||||
.onDuplicateKeyUpdate({ set: updateSet });
|
.onDuplicateKeyUpdate({ set: updateSet });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findAllUsers() {
|
||||||
|
return getDb()
|
||||||
|
.select({
|
||||||
|
id: schema.users.id,
|
||||||
|
unionId: schema.users.unionId,
|
||||||
|
name: schema.users.name,
|
||||||
|
email: schema.users.email,
|
||||||
|
role: schema.users.role,
|
||||||
|
avatar: schema.users.avatar,
|
||||||
|
createdAt: schema.users.createdAt,
|
||||||
|
updatedAt: schema.users.updatedAt,
|
||||||
|
lastSignInAt: schema.users.lastSignInAt,
|
||||||
|
})
|
||||||
|
.from(schema.users)
|
||||||
|
.orderBy(schema.users.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: InsertUser) {
|
||||||
|
const [result] = await getDb().insert(schema.users).values(data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, data: Partial<InsertUser>) {
|
||||||
|
const result = await getDb()
|
||||||
|
.update(schema.users)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
await getDb()
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
}
|
||||||
|
|||||||
+178
-21
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createRouter, publicQuery } from "./middleware";
|
import { createRouter, authedQuery } from "./middleware";
|
||||||
import {
|
import {
|
||||||
findAllRecipients,
|
findAllRecipients,
|
||||||
findRecipientById,
|
findRecipientById,
|
||||||
@@ -9,9 +9,20 @@ import {
|
|||||||
verifyRecipient,
|
verifyRecipient,
|
||||||
deleteRecipient,
|
deleteRecipient,
|
||||||
} from "./queries/recipients";
|
} 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({
|
export const recipientRouter = createRouter({
|
||||||
list: publicQuery
|
list: authedQuery
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
@@ -19,23 +30,66 @@ export const recipientRouter = createRouter({
|
|||||||
placeOfWorshipId: z.number().optional(),
|
placeOfWorshipId: z.number().optional(),
|
||||||
}).optional()
|
}).optional()
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return findAllRecipients(input?.search, input?.status, input?.placeOfWorshipId);
|
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())
|
.input(z.object({ placeOfWorshipId: z.number().optional() }).optional())
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return findPendingRecipients(input?.placeOfWorshipId);
|
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() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return findRecipientById(input.id);
|
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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
nik: z.string().length(16),
|
nik: z.string().length(16),
|
||||||
@@ -51,10 +105,23 @@ export const recipientRouter = createRouter({
|
|||||||
placeOfWorshipId: z.number().min(1),
|
placeOfWorshipId: z.number().min(1),
|
||||||
registeredBy: z.number().optional(),
|
registeredBy: z.number().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
ktpDocument: z.string().min(1),
|
||||||
|
kkDocument: z.string().min(1),
|
||||||
|
sktmDocument: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
return createRecipient({
|
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,
|
...input,
|
||||||
latitude: input.latitude?.toString() ?? null,
|
latitude: input.latitude?.toString() ?? null,
|
||||||
longitude: input.longitude?.toString() ?? null,
|
longitude: input.longitude?.toString() ?? null,
|
||||||
@@ -64,11 +131,26 @@ export const recipientRouter = createRouter({
|
|||||||
phone: input.phone ?? null,
|
phone: input.phone ?? null,
|
||||||
registeredBy: input.registeredBy ?? null,
|
registeredBy: input.registeredBy ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
|
ktpDocument: input.ktpDocument,
|
||||||
|
kkDocument: input.kkDocument,
|
||||||
|
sktmDocument: input.sktmDocument ?? null,
|
||||||
status: "pending",
|
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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -84,17 +166,50 @@ export const recipientRouter = createRouter({
|
|||||||
incomePerMonth: z.number().min(0).optional(),
|
incomePerMonth: z.number().min(0).optional(),
|
||||||
placeOfWorshipId: z.number().optional(),
|
placeOfWorshipId: z.number().optional(),
|
||||||
notes: z.string().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 { 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 };
|
const updateData: Record<string, unknown> = { ...data };
|
||||||
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
|
if (data.latitude !== undefined) updateData.latitude = data.latitude.toString();
|
||||||
if (data.longitude !== undefined) updateData.longitude = data.longitude.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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -103,14 +218,56 @@ export const recipientRouter = createRouter({
|
|||||||
rejectionReason: z.string().optional(),
|
rejectionReason: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
return verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason);
|
// 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() }))
|
.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);
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { recipientRouter } from "./recipientRouter";
|
|||||||
import { distributionRouter } from "./distributionRouter";
|
import { distributionRouter } from "./distributionRouter";
|
||||||
import { dashboardRouter } from "./dashboardRouter";
|
import { dashboardRouter } from "./dashboardRouter";
|
||||||
import { seedRouter } from "./seedRouter";
|
import { seedRouter } from "./seedRouter";
|
||||||
|
import { userRouter } from "./userRouter";
|
||||||
import { createRouter, publicQuery } from "./middleware";
|
import { createRouter, publicQuery } from "./middleware";
|
||||||
|
|
||||||
export const appRouter = createRouter({
|
export const appRouter = createRouter({
|
||||||
@@ -14,6 +15,7 @@ export const appRouter = createRouter({
|
|||||||
distribution: distributionRouter,
|
distribution: distributionRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
seed: seedRouter,
|
seed: seedRouter,
|
||||||
|
user: userRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
text,
|
text,
|
||||||
|
longtext,
|
||||||
timestamp,
|
timestamp,
|
||||||
bigint,
|
bigint,
|
||||||
int,
|
int,
|
||||||
@@ -78,6 +79,9 @@ export const recipients = mysqlTable(
|
|||||||
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
||||||
verifiedAt: timestamp("verified_at"),
|
verifiedAt: timestamp("verified_at"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
ktpDocument: longtext("ktp_document"),
|
||||||
|
kkDocument: longtext("kk_document"),
|
||||||
|
sktmDocument: longtext("sktm_document"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
},
|
},
|
||||||
@@ -104,6 +108,7 @@ export const distributions = mysqlTable(
|
|||||||
quantity: int("quantity").default(1),
|
quantity: int("quantity").default(1),
|
||||||
unit: varchar("unit", { length: 50 }).default("package"),
|
unit: varchar("unit", { length: 50 }).default("package"),
|
||||||
distributionDate: timestamp("distribution_date").defaultNow().notNull(),
|
distributionDate: timestamp("distribution_date").defaultNow().notNull(),
|
||||||
|
handoverPhoto: longtext("handover_photo"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
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
|
||||||
+14
-10
@@ -1,13 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="id">
|
<html lang="id">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta charset="UTF-8" />
|
||||||
<title>WebGIS Poverty Mapping</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
<title>WebGIS Poverty Mapping</title>
|
||||||
</head>
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
<body>
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||||
<div id="root"></div>
|
</head>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getDb } from "../api/queries/connection";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = getDb();
|
||||||
|
try {
|
||||||
|
console.log("Altering recipients table columns to LONGTEXT...");
|
||||||
|
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN ktp_document LONGTEXT`);
|
||||||
|
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN kk_document LONGTEXT`);
|
||||||
|
await db.execute(sql`ALTER TABLE recipients MODIFY COLUMN sktm_document LONGTEXT`);
|
||||||
|
console.log("Database altered successfully!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR ALTERING TABLE:", err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -0,0 +1,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 UsersPage from "./pages/UsersPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
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();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -26,7 +32,7 @@ function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; ad
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminOnly && user.role !== "admin") {
|
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/verification"
|
path="/verification"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={["admin", "officer"]}>
|
||||||
<VerificationPage />
|
<VerificationPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -80,7 +86,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute adminOnly>
|
<ProtectedRoute allowedRoles={["admin"]}>
|
||||||
<UsersPage />
|
<UsersPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -88,7 +94,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute allowedRoles={["admin"]}>
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ const menuItems = [
|
|||||||
{ icon: Map, label: "Peta GIS", path: "/map" },
|
{ icon: Map, label: "Peta GIS", path: "/map" },
|
||||||
{ icon: Users, label: "Penerima Bantuan", path: "/recipients" },
|
{ icon: Users, label: "Penerima Bantuan", path: "/recipients" },
|
||||||
{ icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" },
|
{ icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" },
|
||||||
{ icon: ClipboardCheck, label: "Verifikasi", path: "/verification" },
|
{
|
||||||
{ icon: Shield, label: "Pengguna", path: "/users", adminOnly: true },
|
icon: ClipboardCheck,
|
||||||
{ icon: Settings, label: "Pengaturan", path: "/settings" },
|
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 }) {
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
@@ -46,11 +51,10 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const isAdmin = user?.role === "admin";
|
|
||||||
const activeItem = menuItems.find((item) => item.path === location.pathname);
|
const activeItem = menuItems.find((item) => item.path === location.pathname);
|
||||||
|
|
||||||
const filteredItems = menuItems.filter(
|
const filteredItems = menuItems.filter(
|
||||||
(item) => !item.adminOnly || isAdmin
|
(item) => !item.roles || (user?.role && item.roles.includes(user.role))
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarWidth = collapsed ? 72 : 260;
|
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" />
|
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
{!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>
|
</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 { 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -44,13 +48,42 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
other: "Lainnya",
|
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() {
|
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 [search, setSearch] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||||
|
const [isAidOpen, setIsAidOpen] = useState(false);
|
||||||
const [selectedPlace, setSelectedPlace] = useState<number | null>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
type: "mosque" as "mosque" | "church" | "temple" | "vihara" | "other",
|
type: "mosque" as "mosque" | "church" | "temple" | "vihara" | "other",
|
||||||
@@ -68,6 +101,14 @@ export default function PlacesOfWorshipPage() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
type: typeFilter === "all" ? undefined : typeFilter,
|
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({
|
const createMutation = trpc.placeOfWorship.create.useMutation({
|
||||||
onSuccess: () => {
|
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 = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -158,6 +215,42 @@ export default function PlacesOfWorshipPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedPlaceData = places?.find((p) => p.id === selectedPlace);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -166,73 +259,81 @@ export default function PlacesOfWorshipPage() {
|
|||||||
<h2 className="text-2xl font-bold tracking-tight">Rumah Ibadah</h2>
|
<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>
|
<p className="text-muted-foreground mt-1 text-sm">Kelola data rumah ibadah dan cakupan wilayahnya</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
{canModify && (
|
||||||
<DialogTrigger asChild>
|
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<Button className="rounded-xl" size="sm">
|
<DialogTrigger asChild>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button className="rounded-xl" size="sm">
|
||||||
Tambah Rumah Ibadah
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Tambah Rumah Ibadah
|
||||||
</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"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</DialogTrigger>
|
||||||
</DialogContent>
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
</Dialog>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -298,16 +399,25 @@ export default function PlacesOfWorshipPage() {
|
|||||||
<span>{place.contactPhone}</span>
|
<span>{place.contactPhone}</span>
|
||||||
</div>
|
</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); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setSelectedPlace(place.id); setIsViewOpen(true); }}>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
|
{canModify && (
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(place)}>
|
||||||
</Button>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => { if (confirm("Hapus rumah ibadah ini?")) deleteMutation.mutate({ id: place.id }); }}>
|
</Button>
|
||||||
<Trash2 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -316,14 +426,200 @@ export default function PlacesOfWorshipPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Edit Dialog */}
|
||||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Edit Rumah Ibadah</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Edit Rumah Ibadah</DialogTitle></DialogHeader>
|
||||||
<form onSubmit={handleUpdate} className="space-y-4">
|
<form onSubmit={handleUpdate} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Nama</Label>
|
<Label className="text-xs">Nama Tempat Ibadah</Label>
|
||||||
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="h-9" />
|
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -348,6 +644,12 @@ export default function PlacesOfWorshipPage() {
|
|||||||
<Label className="text-xs">Alamat</Label>
|
<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) => setFormData({ ...formData, address: e.target.value })} className="h-9" />
|
||||||
</div>
|
</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="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">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="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 { 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -43,7 +47,44 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: "suspended", label: "Ditangguhkan" },
|
{ 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() {
|
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 [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
@@ -57,12 +98,15 @@ export default function RecipientsPage() {
|
|||||||
gender: "male" as "male" | "female",
|
gender: "male" as "male" | "female",
|
||||||
address: "",
|
address: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
familyMembers: 1,
|
familyMembers: 1 as number | "",
|
||||||
incomePerMonth: 0,
|
incomePerMonth: 0,
|
||||||
placeOfWorshipId: 1,
|
placeOfWorshipId: 0,
|
||||||
notes: "",
|
notes: "",
|
||||||
latitude: "",
|
latitude: "",
|
||||||
longitude: "",
|
longitude: "",
|
||||||
|
ktpDocument: "",
|
||||||
|
kkDocument: "",
|
||||||
|
sktmDocument: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -73,12 +117,34 @@ export default function RecipientsPage() {
|
|||||||
|
|
||||||
const { data: places } = trpc.placeOfWorship.list.useQuery({});
|
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({
|
const createMutation = trpc.recipient.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.recipient.list.invalidate();
|
utils.recipient.list.invalidate();
|
||||||
utils.dashboard.stats.invalidate();
|
utils.dashboard.stats.invalidate();
|
||||||
setIsAddOpen(false);
|
setIsAddOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
toast.success("Penerima bantuan baru berhasil didaftarkan!");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || "Gagal mendaftarkan penerima bantuan baru.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +153,10 @@ export default function RecipientsPage() {
|
|||||||
utils.recipient.list.invalidate();
|
utils.recipient.list.invalidate();
|
||||||
setIsEditOpen(false);
|
setIsEditOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
toast.success("Data penerima bantuan berhasil diperbarui!");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || "Gagal memperbarui data penerima.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +164,10 @@ export default function RecipientsPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.recipient.list.invalidate();
|
utils.recipient.list.invalidate();
|
||||||
utils.dashboard.stats.invalidate();
|
utils.dashboard.stats.invalidate();
|
||||||
|
toast.success("Penerima bantuan berhasil dihapus.");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || "Gagal menghapus data penerima.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,17 +181,46 @@ export default function RecipientsPage() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
familyMembers: 1,
|
familyMembers: 1,
|
||||||
incomePerMonth: 0,
|
incomePerMonth: 0,
|
||||||
placeOfWorshipId: 1,
|
placeOfWorshipId: 0,
|
||||||
notes: "",
|
notes: "",
|
||||||
latitude: "",
|
latitude: "",
|
||||||
longitude: "",
|
longitude: "",
|
||||||
|
ktpDocument: "",
|
||||||
|
kkDocument: "",
|
||||||
|
sktmDocument: "",
|
||||||
});
|
});
|
||||||
setSelectedRecipient(null);
|
setSelectedRecipient(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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({
|
createMutation.mutate({
|
||||||
nik: formData.nik,
|
nik: formData.nik,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -125,12 +228,15 @@ export default function RecipientsPage() {
|
|||||||
gender: formData.gender,
|
gender: formData.gender,
|
||||||
address: formData.address || undefined,
|
address: formData.address || undefined,
|
||||||
phone: formData.phone || undefined,
|
phone: formData.phone || undefined,
|
||||||
familyMembers: formData.familyMembers,
|
familyMembers: Number(formData.familyMembers) || 1,
|
||||||
incomePerMonth: formData.incomePerMonth,
|
incomePerMonth: formData.incomePerMonth,
|
||||||
placeOfWorshipId: formData.placeOfWorshipId,
|
placeOfWorshipId: resolvedPlaceOfWorshipId,
|
||||||
notes: formData.notes || undefined,
|
notes: formData.notes || undefined,
|
||||||
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
||||||
longitude: formData.longitude ? parseFloat(formData.longitude) : 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 ?? "",
|
notes: recipient.notes ?? "",
|
||||||
latitude: recipient.latitude?.toString() ?? "",
|
latitude: recipient.latitude?.toString() ?? "",
|
||||||
longitude: recipient.longitude?.toString() ?? "",
|
longitude: recipient.longitude?.toString() ?? "",
|
||||||
|
ktpDocument: recipient.ktpDocument ?? "",
|
||||||
|
kkDocument: recipient.kkDocument ?? "",
|
||||||
|
sktmDocument: recipient.sktmDocument ?? "",
|
||||||
});
|
});
|
||||||
setSelectedRecipient(recipient.id);
|
setSelectedRecipient(recipient.id);
|
||||||
setIsEditOpen(true);
|
setIsEditOpen(true);
|
||||||
@@ -155,7 +264,26 @@ export default function RecipientsPage() {
|
|||||||
|
|
||||||
const handleUpdate = (e: React.FormEvent) => {
|
const handleUpdate = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
||||||
if (!selectedRecipient) return;
|
if (!selectedRecipient) return;
|
||||||
|
|
||||||
|
if (!formData.nik.trim()) {
|
||||||
|
toast.error("NIK harus diisi.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.nik.length !== 16) {
|
||||||
|
toast.error("NIK harus berjumlah tepat 16 digit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error("Nama Lengkap harus diisi.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resolvedPlaceOfWorshipId) {
|
||||||
|
toast.error("Gagal menentukan Rumah Ibadah terdekat. Harap pilih koordinat lokasi di peta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
id: selectedRecipient,
|
id: selectedRecipient,
|
||||||
nik: formData.nik,
|
nik: formData.nik,
|
||||||
@@ -164,12 +292,15 @@ export default function RecipientsPage() {
|
|||||||
gender: formData.gender,
|
gender: formData.gender,
|
||||||
address: formData.address || undefined,
|
address: formData.address || undefined,
|
||||||
phone: formData.phone || undefined,
|
phone: formData.phone || undefined,
|
||||||
familyMembers: formData.familyMembers,
|
familyMembers: Number(formData.familyMembers) || 1,
|
||||||
incomePerMonth: formData.incomePerMonth,
|
incomePerMonth: formData.incomePerMonth,
|
||||||
placeOfWorshipId: formData.placeOfWorshipId,
|
placeOfWorshipId: resolvedPlaceOfWorshipId,
|
||||||
notes: formData.notes || undefined,
|
notes: formData.notes || undefined,
|
||||||
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
||||||
longitude: formData.longitude ? parseFloat(formData.longitude) : 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 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<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
|
Kelola data warga penerima bantuan sosial
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
{canModify && (
|
||||||
<DialogTrigger asChild>
|
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<Button className="rounded-xl" size="sm">
|
<DialogTrigger asChild>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button className="rounded-xl" size="sm">
|
||||||
Tambah Penerima
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Tambah Penerima
|
||||||
</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"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</DialogTrigger>
|
||||||
</DialogContent>
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
</Dialog>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -338,6 +531,7 @@ export default function RecipientsPage() {
|
|||||||
<TableHead className="text-xs">Nama</TableHead>
|
<TableHead className="text-xs">Nama</TableHead>
|
||||||
<TableHead className="text-xs">Status</TableHead>
|
<TableHead className="text-xs">Status</TableHead>
|
||||||
<TableHead className="text-xs">Rumah Ibadah</TableHead>
|
<TableHead className="text-xs">Rumah Ibadah</TableHead>
|
||||||
|
<TableHead className="text-xs">Jarak</TableHead>
|
||||||
<TableHead className="text-xs">Keluarga</TableHead>
|
<TableHead className="text-xs">Keluarga</TableHead>
|
||||||
<TableHead className="text-xs">Pendapatan</TableHead>
|
<TableHead className="text-xs">Pendapatan</TableHead>
|
||||||
<TableHead className="text-xs">Aksi</TableHead>
|
<TableHead className="text-xs">Aksi</TableHead>
|
||||||
@@ -352,9 +546,15 @@ export default function RecipientsPage() {
|
|||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"}
|
{places?.find((p) => p.id === r.placeOfWorshipId)?.name ?? "-"}
|
||||||
</TableCell>
|
</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">{r.familyMembers}</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
Rp {(r.incomePerMonth ?? 0).toLocaleString("id-ID")}
|
{formatRupiah(r.incomePerMonth ?? 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -366,26 +566,32 @@ export default function RecipientsPage() {
|
|||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{canModify && (
|
||||||
variant="ghost"
|
<>
|
||||||
size="icon"
|
<Button
|
||||||
className="h-7 w-7"
|
variant="ghost"
|
||||||
onClick={() => handleEdit(r)}
|
size="icon"
|
||||||
>
|
className="h-7 w-7"
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
onClick={() => handleEdit(r)}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
{isAdmin && (
|
||||||
className="h-7 w-7 text-destructive"
|
<Button
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
if (confirm("Hapus penerima ini?")) {
|
size="icon"
|
||||||
deleteMutation.mutate({ id: r.id });
|
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>
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -405,21 +611,21 @@ export default function RecipientsPage() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">NIK</Label>
|
<Label className="text-xs">NIK</Label>
|
||||||
<Input value={formData.nik} onChange={(e) => setFormData({ ...formData, nik: e.target.value })} className="h-9" />
|
<Input value={formData.nik} onChange={(e) => updateFormData({ nik: e.target.value })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Nama</Label>
|
<Label className="text-xs">Nama</Label>
|
||||||
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="h-9" />
|
<Input value={formData.name} onChange={(e) => updateFormData({ name: e.target.value })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Tanggal Lahir</Label>
|
<Label className="text-xs">Tanggal Lahir</Label>
|
||||||
<Input type="date" value={formData.birthDate} onChange={(e) => setFormData({ ...formData, birthDate: e.target.value })} className="h-9" />
|
<Input type="date" value={formData.birthDate} onChange={(e) => updateFormData({ birthDate: e.target.value })} className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Jenis Kelamin</Label>
|
<Label className="text-xs">Jenis Kelamin</Label>
|
||||||
<Select value={formData.gender} onValueChange={(v: "male" | "female") => setFormData({ ...formData, gender: v })}>
|
<Select value={formData.gender} onValueChange={(v: "male" | "female") => updateFormData({ gender: v })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="male">Laki-laki</SelectItem>
|
<SelectItem value="male">Laki-laki</SelectItem>
|
||||||
@@ -430,44 +636,65 @@ export default function RecipientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Alamat</Label>
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Telepon</Label>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Jumlah Keluarga</Label>
|
<Label className="text-xs">Jumlah Anggota Keluarga</Label>
|
||||||
<Input type="number" value={formData.familyMembers} onChange={(e) => setFormData({ ...formData, familyMembers: parseInt(e.target.value) || 1 })} className="h-9" />
|
<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>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Pendapatan/Bulan</Label>
|
<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" />
|
<Input
|
||||||
</div>
|
value={formatRupiah(formData.incomePerMonth)}
|
||||||
<div className="space-y-1.5">
|
onChange={(e) => updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })}
|
||||||
<Label className="text-xs">Rumah Ibadah</Label>
|
className="h-9"
|
||||||
<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>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||||
<div className="space-y-1.5">
|
<p className="font-medium text-foreground">Rumah ibadah terdekat (otomatis)</p>
|
||||||
<Label className="text-xs">Latitude</Label>
|
<p>
|
||||||
<Input value={formData.latitude} onChange={(e) => setFormData({ ...formData, latitude: e.target.value })} className="h-9" />
|
{nearestPlaceForForm
|
||||||
</div>
|
? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}`
|
||||||
<div className="space-y-1.5">
|
: "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."}
|
||||||
<Label className="text-xs">Longitude</Label>
|
</p>
|
||||||
<Input value={formData.longitude} onChange={(e) => setFormData({ ...formData, longitude: e.target.value })} className="h-9" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
|
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
|
||||||
{updateMutation.isPending ? "Memperbarui..." : "Perbarui"}
|
{updateMutation.isPending ? "Memperbarui..." : "Perbarui"}
|
||||||
@@ -478,7 +705,7 @@ export default function RecipientsPage() {
|
|||||||
|
|
||||||
{/* View Dialog */}
|
{/* View Dialog */}
|
||||||
<Dialog open={isViewOpen} onOpenChange={setIsViewOpen}>
|
<Dialog open={isViewOpen} onOpenChange={setIsViewOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Detail Penerima</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Detail Penerima</DialogTitle></DialogHeader>
|
||||||
{selectedRec && (
|
{selectedRec && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
@@ -506,6 +733,16 @@ export default function RecipientsPage() {
|
|||||||
{selectedRec.notes && (
|
{selectedRec.notes && (
|
||||||
<div className="text-xs bg-muted p-2 rounded-lg"><span className="text-muted-foreground">Catatan:</span> {selectedRec.notes}</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
+497
-65
@@ -1,8 +1,44 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { trpc } from "@/providers/trpc";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Shield, User, UserCog, Crown } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { 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> = {
|
const ROLE_ICONS: Record<string, typeof Shield> = {
|
||||||
admin: Crown,
|
admin: Crown,
|
||||||
@@ -11,9 +47,9 @@ const ROLE_ICONS: Record<string, typeof Shield> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
admin: "bg-violet-100 text-violet-700",
|
admin: "bg-violet-100 text-violet-700 hover:bg-violet-200",
|
||||||
officer: "bg-blue-100 text-blue-700",
|
officer: "bg-blue-100 text-blue-700 hover:bg-blue-200",
|
||||||
manager: "bg-slate-100 text-slate-700",
|
manager: "bg-slate-100 text-slate-700 hover:bg-slate-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -24,15 +60,176 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// Dialog States
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<any>(null); // null = Add New
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<any>(null);
|
||||||
|
|
||||||
|
// Form States
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<"admin" | "officer" | "manager">("manager");
|
||||||
|
const [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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Manajemen Pengguna</h2>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<h2 className="text-2xl font-bold tracking-tight">Manajemen Pengguna</h2>
|
||||||
Kelola pengguna sistem dan peran aksesnya
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
</p>
|
Kelola pengguna sistem, peran hak akses, dan detail akun petugas/pengelola.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenAdd} className="h-9 text-xs rounded-xl flex items-center gap-1.5 self-start sm:self-auto">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Tambah Pengguna
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role Info Cards */}
|
{/* Role Info Cards */}
|
||||||
@@ -49,9 +246,9 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm">{label}</h3>
|
<h3 className="font-semibold text-sm">{label}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{role === "admin" && "Akses penuh ke seluruh sistem"}
|
{role === "admin" && "Akses penuh untuk konfigurasi pengguna, sistem, dan rumah ibadah."}
|
||||||
{role === "officer" && "Verifikasi dan setujui pendaftaran warga"}
|
{role === "officer" && "Verifikasi kelayakan berkas KTP, KK, SKTM penerima bantuan sosial."}
|
||||||
{role === "manager" && "Mendaftarkan warga dan mengelola distribusi"}
|
{role === "manager" && "Menginput calon penerima dan mencatat riwayat pembagian bantuan."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,92 +258,172 @@ export default function UsersPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current User */}
|
{/* Main User List Section */}
|
||||||
<Card className="rounded-2xl shadow-sm border-0">
|
<Card className="rounded-2xl shadow-sm border-0 overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-3 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-muted/10">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
<User className="h-4 w-4 text-primary" />
|
||||||
Informasi Pengguna Saat Ini
|
Daftar Pengguna Aktif
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="relative w-full sm:w-64">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Cari nama atau email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8 h-9 text-xs rounded-xl bg-background border-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
{!currentUser ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<div className="p-6 space-y-3">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-muted-foreground text-sm">
|
||||||
|
Tidak ada pengguna yang ditemukan.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4 p-3 bg-muted/30 rounded-xl">
|
<div className="overflow-x-auto">
|
||||||
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-bold">
|
<table className="w-full text-sm">
|
||||||
{currentUser.name?.charAt(0).toUpperCase() ?? "U"}
|
<thead>
|
||||||
</div>
|
<tr className="border-b bg-muted/5 text-left text-xs font-semibold text-muted-foreground">
|
||||||
<div className="flex-1">
|
<th className="py-3 px-4">Nama</th>
|
||||||
<div className="flex items-center gap-2">
|
<th className="py-3 px-4">Email</th>
|
||||||
<h3 className="font-semibold">{currentUser.name ?? "User"}</h3>
|
<th className="py-3 px-4">Peran</th>
|
||||||
<Badge className={`text-[10px] capitalize ${ROLE_COLORS[currentUser.role ?? "manager"]}`}>
|
<th className="py-3 px-4">Terakhir Masuk</th>
|
||||||
{ROLE_LABELS[currentUser.role ?? "manager"]}
|
<th className="py-3 px-4 text-right">Aksi</th>
|
||||||
</Badge>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<p className="text-sm text-muted-foreground">{currentUser.email ?? "-"}</p>
|
<tbody>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
{filteredUsers.map((u) => {
|
||||||
Login terakhir: {currentUser.lastSignInAt ? new Date(currentUser.lastSignInAt).toLocaleDateString("id-ID") : "-"}
|
const RoleIcon = ROLE_ICONS[u.role] || User;
|
||||||
</p>
|
const isSelf = !!(currentUser && currentUser.id === u.id);
|
||||||
</div>
|
return (
|
||||||
|
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/10 transition-colors">
|
||||||
|
<td className="py-3 px-4 font-medium flex items-center gap-2">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold">
|
||||||
|
{u.name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{u.name}</span>
|
||||||
|
{isSelf && (
|
||||||
|
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-semibold">
|
||||||
|
Anda
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-muted-foreground">{u.email}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant="outline" className={`text-[10px] gap-1 rounded-lg border-0 font-medium ${ROLE_COLORS[u.role]}`}>
|
||||||
|
<RoleIcon className="h-3 w-3" />
|
||||||
|
{ROLE_LABELS[u.role]}
|
||||||
|
</Badge>
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Permissions Table */}
|
{/* Permissions Table (Access Matrix) */}
|
||||||
<Card className="rounded-2xl shadow-sm border-0">
|
<Card className="rounded-2xl shadow-sm border-0">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
Matriks Izin Akses
|
Matriks Izin Akses Sistem
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b">
|
<tr className="border-b text-left text-xs font-semibold text-muted-foreground">
|
||||||
<th className="text-left py-3 px-3 font-medium text-muted-foreground text-xs">Fitur</th>
|
<th className="py-3 px-3">Fitur</th>
|
||||||
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Admin</th>
|
<th className="text-center py-3 px-3">Admin</th>
|
||||||
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Petugas</th>
|
<th className="text-center py-3 px-3">Petugas</th>
|
||||||
<th className="text-center py-3 px-3 font-medium text-muted-foreground text-xs">Pengelola</th>
|
<th className="text-center py-3 px-3">Pengelola</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[
|
{[
|
||||||
{ feature: "Dashboard", admin: true, officer: true, manager: true },
|
{ feature: "Dashboard Utama", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Peta GIS", admin: true, officer: true, manager: true },
|
{ feature: "Visualisasi Peta GIS", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true },
|
{ feature: "Data Penerima Bantuan (CRUD)", admin: true, officer: "R", manager: true },
|
||||||
{ feature: "Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" },
|
{ feature: "Data Rumah Ibadah (CRUD)", admin: true, officer: "R", manager: "R" },
|
||||||
{ feature: "Verifikasi Penerima", admin: true, officer: true, manager: false },
|
{ feature: "Pencatatan Distribusi Bantuan", admin: true, officer: true, manager: true },
|
||||||
{ feature: "Distribusi Bantuan", admin: true, officer: true, manager: true },
|
{ feature: "Verifikasi Kelayakan Dokumen", admin: true, officer: true, manager: false },
|
||||||
{ feature: "Manajemen Pengguna", admin: true, officer: false, manager: false },
|
{ feature: "Manajemen Pengguna (CRUD)", admin: true, officer: false, manager: false },
|
||||||
{ feature: "Pengaturan Sistem", admin: true, officer: false, manager: false },
|
{ feature: "Pengaturan Sistem", admin: true, officer: false, manager: false },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/30">
|
<tr key={row.feature} className="border-b last:border-0 hover:bg-muted/5 transition-colors">
|
||||||
<td className="py-3 px-3 text-xs font-medium">{row.feature}</td>
|
<td className="py-2.5 px-3 text-xs font-medium">{row.feature}</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.admin === true ? (
|
{row.admin === true ? (
|
||||||
<span className="text-emerald-600 text-xs">Full</span>
|
<span className="text-emerald-600 font-semibold text-xs">Full</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-xs">-</span>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.officer === true ? (
|
{row.officer === true ? (
|
||||||
<span className="text-emerald-600 text-xs">Full</span>
|
<span className="text-emerald-600 font-semibold text-xs">Full</span>
|
||||||
) : row.officer === "R" ? (
|
) : row.officer === "R" ? (
|
||||||
<span className="text-blue-600 text-xs">Read</span>
|
<span className="text-blue-600 font-semibold text-xs">Lihat</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-xs">-</span>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-center">
|
<td className="py-2.5 px-3 text-center">
|
||||||
{row.manager === true ? (
|
{row.manager === true ? (
|
||||||
<span className="text-emerald-600 text-xs">Full</span>
|
<span className="text-emerald-600 font-semibold text-xs">Full</span>
|
||||||
) : row.manager === "R" ? (
|
) : row.manager === "R" ? (
|
||||||
<span className="text-blue-600 text-xs">Read</span>
|
<span className="text-blue-600 font-semibold text-xs">Lihat</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-xs">-</span>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
)}
|
)}
|
||||||
@@ -158,6 +435,161 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Form Dialog: Add/Edit User */}
|
||||||
|
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-bold">
|
||||||
|
{selectedUser ? "Ubah Data Pengguna" : "Tambah Pengguna Baru"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs text-muted-foreground">
|
||||||
|
{selectedUser
|
||||||
|
? "Ubah data akun pengguna di bawah ini. Kosongkan sandi jika tidak ingin dirubah."
|
||||||
|
: "Masukkan nama, email, sandi, serta hak akses untuk akun pengguna baru."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-3 text-xs rounded-xl bg-red-50 text-red-600 font-medium">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<UserCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Nama Lengkap
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="cth: Bapak Sulaiman"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-9 text-xs rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Alamat Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="cth: user@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="h-9 text-xs rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Kata Sandi
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={selectedUser ? "•••••• (kosongkan jika tidak ingin dirubah)" : "Min. 6 karakter"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="h-9 text-xs rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Peran Hak Akses
|
||||||
|
</Label>
|
||||||
|
<Select value={role} onValueChange={(val: any) => setRole(val)}>
|
||||||
|
<SelectTrigger className="h-9 text-xs rounded-xl">
|
||||||
|
<SelectValue placeholder="Pilih Peran" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="admin" className="text-xs">Administrator</SelectItem>
|
||||||
|
<SelectItem value="officer" className="text-xs">Petugas Verifikasi (Officer)</SelectItem>
|
||||||
|
<SelectItem value="manager" className="text-xs">Pengelola Rumah Ibadah (Manager)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { DocumentUploadField } from "@/components/DocumentUploadField";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -183,6 +184,16 @@ export default function VerificationPage() {
|
|||||||
{r.notes && (
|
{r.notes && (
|
||||||
<p className="text-xs bg-muted p-2 rounded-lg">{r.notes}</p>
|
<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>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<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