diff --git a/api/distributionRouter.ts b/api/distributionRouter.ts index e1c7a37..9743631 100644 --- a/api/distributionRouter.ts +++ b/api/distributionRouter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, authedQuery } from "./middleware"; import { findAllDistributions, findDistributionById, @@ -8,6 +8,8 @@ import { } 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[0]) { try { @@ -18,18 +20,35 @@ async function logActivitySafe(payload: Parameters[0]) } export const distributionRouter = createRouter({ - list: publicQuery + list: authedQuery .input( z.object({ placeOfWorshipId: z.number().optional(), recipientId: z.number().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllDistributions(input?.placeOfWorshipId, input?.recipientId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data distribusi ini.", + }); + } + return findAllDistributions([input.placeOfWorshipId], input?.recipientId); + } + return findAllDistributions(placeIds, input?.recipientId); + } + return findAllDistributions( + input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined, + input?.recipientId + ); }), - create: publicQuery + create: authedQuery .input( z.object({ placeOfWorshipId: z.number().min(1), @@ -42,7 +61,17 @@ export const distributionRouter = createRouter({ notes: z.string().optional(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda hanya berwenang mencatat distribusi bantuan untuk rumah ibadah yang Anda kelola.", + }); + } + } + const created = await createDistribution({ ...input, amount: input.amount.toString(), @@ -53,7 +82,7 @@ export const distributionRouter = createRouter({ if (created) { const recipient = await findRecipientById(created.recipientId); await logActivitySafe({ - userId: input.distributedBy ?? null, + userId: ctx.user.id, action: "CREATE", entityType: "distribution", entityId: created.id, @@ -66,15 +95,29 @@ export const distributionRouter = createRouter({ return created; }), - delete: publicQuery + delete: authedQuery .input(z.object({ id: z.number() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const target = await findDistributionById(input.id); + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Data tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(target.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang menghapus data distribusi ini.", + }); + } + } + await deleteDistribution(input.id); if (target) { await logActivitySafe({ - userId: target.distributedBy ?? null, + userId: ctx.user.id, action: "DELETE", entityType: "distribution", entityId: target.id, diff --git a/api/placeOfWorshipRouter.ts b/api/placeOfWorshipRouter.ts index 046c806..edd7c61 100644 --- a/api/placeOfWorshipRouter.ts +++ b/api/placeOfWorshipRouter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, publicQuery, adminQuery } from "./middleware"; import { findAllPlaces, findPlaceById, @@ -26,8 +26,9 @@ export const placeOfWorshipRouter = createRouter({ active: z.string().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllPlaces(input?.search, input?.type, input?.active); + .query(async ({ input, ctx }) => { + const managerId = ctx.user?.role === "manager" ? ctx.user.id : undefined; + return findAllPlaces(input?.search, input?.type, input?.active, managerId); }), byId: publicQuery @@ -36,7 +37,7 @@ export const placeOfWorshipRouter = createRouter({ return findPlaceById(input.id); }), - create: publicQuery + create: adminQuery .input( z.object({ name: z.string().min(1), @@ -72,7 +73,7 @@ export const placeOfWorshipRouter = createRouter({ return created; }), - update: publicQuery + update: adminQuery .input( z.object({ id: z.number(), @@ -111,7 +112,7 @@ export const placeOfWorshipRouter = createRouter({ return updated; }), - delete: publicQuery + delete: adminQuery .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const target = await findPlaceById(input.id); diff --git a/api/queries/distributions.ts b/api/queries/distributions.ts index dd61635..00519f4 100644 --- a/api/queries/distributions.ts +++ b/api/queries/distributions.ts @@ -1,12 +1,22 @@ -import { eq, and, desc, count, sql } from "drizzle-orm"; +import { eq, and, desc, count, sql, inArray } from "drizzle-orm"; import { getDb } from "./connection"; import { distributions } from "@db/schema"; import type { InsertDistribution } from "@db/schema"; -export async function findAllDistributions(placeOfWorshipId?: number, recipientId?: number) { +export async function findAllDistributions(placeOfWorshipIds?: number | number[], recipientId?: number) { const db = getDb(); const conditions = []; - if (placeOfWorshipId) conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(distributions.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(distributions.placeOfWorshipId, placeOfWorshipIds)); + } + } if (recipientId) conditions.push(eq(distributions.recipientId, recipientId)); const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(distributions).where(where).orderBy(desc(distributions.distributionDate)); diff --git a/api/queries/placesOfWorship.ts b/api/queries/placesOfWorship.ts index 8beae60..96495c2 100644 --- a/api/queries/placesOfWorship.ts +++ b/api/queries/placesOfWorship.ts @@ -3,7 +3,7 @@ import { getDb } from "./connection"; import { placesOfWorship } from "@db/schema"; import type { InsertPlaceOfWorship } from "@db/schema"; -export async function findAllPlaces(search?: string, type?: string, active?: string) { +export async function findAllPlaces(search?: string, type?: string, active?: string, managerId?: number) { const db = getDb(); const conditions = []; if (search) { @@ -15,6 +15,9 @@ export async function findAllPlaces(search?: string, type?: string, active?: str if (active && active !== "all") { conditions.push(eq(placesOfWorship.isActive, active as "yes" | "no")); } + if (managerId !== undefined) { + conditions.push(eq(placesOfWorship.managerId, managerId)); + } const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(placesOfWorship).where(where).orderBy(desc(placesOfWorship.createdAt)); } @@ -25,6 +28,21 @@ export async function findPlaceById(id: number) { return rows.at(0) ?? null; } +export async function findPlaceByManagerId(managerId: number) { + const db = getDb(); + const rows = await db.select().from(placesOfWorship).where(eq(placesOfWorship.managerId, managerId)).limit(1); + return rows.at(0) ?? null; +} + +export async function findPlaceIdsByManagerId(managerId: number) { + const db = getDb(); + const rows = await db + .select({ id: placesOfWorship.id }) + .from(placesOfWorship) + .where(eq(placesOfWorship.managerId, managerId)); + return rows.map((r) => r.id); +} + export async function createPlace(data: InsertPlaceOfWorship) { const db = getDb(); const [{ id }] = await db.insert(placesOfWorship).values(data).$returningId(); diff --git a/api/queries/recipients.ts b/api/queries/recipients.ts index 29ecba0..b592bae 100644 --- a/api/queries/recipients.ts +++ b/api/queries/recipients.ts @@ -1,4 +1,4 @@ -import { eq, like, and, count, desc } from "drizzle-orm"; +import { eq, like, and, count, desc, inArray, sql } from "drizzle-orm"; import { getDb } from "./connection"; import { recipients } from "@db/schema"; import type { InsertRecipient } from "@db/schema"; @@ -6,7 +6,7 @@ import type { InsertRecipient } from "@db/schema"; export async function findAllRecipients( search?: string, status?: string, - placeOfWorshipId?: number + placeOfWorshipIds?: number | number[] ) { const db = getDb(); const conditions = []; @@ -16,8 +16,16 @@ export async function findAllRecipients( if (status && status !== "all") { conditions.push(eq(recipients.status, status as "pending" | "active" | "rejected" | "suspended")); } - if (placeOfWorshipId) { - conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds)); + } } const where = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(recipients).where(where).orderBy(desc(recipients.createdAt)); @@ -29,11 +37,19 @@ export async function findRecipientById(id: number) { return rows.at(0) ?? null; } -export async function findPendingRecipients(placeOfWorshipId?: number) { +export async function findPendingRecipients(placeOfWorshipIds?: number | number[]) { const db = getDb(); const conditions = [eq(recipients.status, "pending")]; - if (placeOfWorshipId) { - conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipId)); + if (placeOfWorshipIds !== undefined) { + if (Array.isArray(placeOfWorshipIds)) { + if (placeOfWorshipIds.length > 0) { + conditions.push(inArray(recipients.placeOfWorshipId, placeOfWorshipIds)); + } else { + conditions.push(sql`1 = 0`); + } + } else { + conditions.push(eq(recipients.placeOfWorshipId, placeOfWorshipIds)); + } } return db.select().from(recipients) .where(and(...conditions)) diff --git a/api/recipientRouter.ts b/api/recipientRouter.ts index 6c225f6..ef38ba5 100644 --- a/api/recipientRouter.ts +++ b/api/recipientRouter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createRouter, publicQuery } from "./middleware"; +import { createRouter, authedQuery } from "./middleware"; import { findAllRecipients, findRecipientById, @@ -10,6 +10,8 @@ import { deleteRecipient, } from "./queries/recipients"; import { createActivityLog } from "./queries/activityLogs"; +import { findPlaceIdsByManagerId } from "./queries/placesOfWorship"; +import { TRPCError } from "@trpc/server"; async function logActivitySafe(payload: Parameters[0]) { try { @@ -20,7 +22,7 @@ async function logActivitySafe(payload: Parameters[0]) } export const recipientRouter = createRouter({ - list: publicQuery + list: authedQuery .input( z.object({ search: z.string().optional(), @@ -28,23 +30,66 @@ export const recipientRouter = createRouter({ placeOfWorshipId: z.number().optional(), }).optional() ) - .query(async ({ input }) => { - return findAllRecipients(input?.search, input?.status, input?.placeOfWorshipId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data rumah ibadah ini.", + }); + } + return findAllRecipients(input?.search, input?.status, [input.placeOfWorshipId]); + } + return findAllRecipients(input?.search, input?.status, placeIds); + } + return findAllRecipients( + input?.search, + input?.status, + input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined + ); }), - pending: publicQuery + pending: authedQuery .input(z.object({ placeOfWorshipId: z.number().optional() }).optional()) - .query(async ({ input }) => { - return findPendingRecipients(input?.placeOfWorshipId); + .query(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (placeIds.length === 0) return []; + if (input?.placeOfWorshipId) { + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data rumah ibadah ini.", + }); + } + return findPendingRecipients([input.placeOfWorshipId]); + } + return findPendingRecipients(placeIds); + } + return findPendingRecipients(input?.placeOfWorshipId ? [input.placeOfWorshipId] : undefined); }), - byId: publicQuery + byId: authedQuery .input(z.object({ id: z.number() })) - .query(async ({ input }) => { - return findRecipientById(input.id); + .query(async ({ input, ctx }) => { + const recipient = await findRecipientById(input.id); + if (!recipient) return null; + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(recipient.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengakses data penerima ini.", + }); + } + } + return recipient; }), - create: publicQuery + create: authedQuery .input( z.object({ nik: z.string().length(16), @@ -65,7 +110,17 @@ export const recipientRouter = createRouter({ sktmDocument: z.string().optional(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(input.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda hanya berwenang mendaftarkan penerima untuk rumah ibadah yang Anda kelola.", + }); + } + } + const created = await createRecipient({ ...input, latitude: input.latitude?.toString() ?? null, @@ -84,7 +139,7 @@ export const recipientRouter = createRouter({ if (created) { await logActivitySafe({ - userId: input.registeredBy ?? null, + userId: ctx.user.id, action: "CREATE", entityType: "recipient", entityId: created.id, @@ -95,7 +150,7 @@ export const recipientRouter = createRouter({ return created; }), - update: publicQuery + update: authedQuery .input( z.object({ id: z.number(), @@ -116,9 +171,26 @@ export const recipientRouter = createRouter({ sktmDocument: z.string().optional(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const { id, ...data } = input; const beforeUpdate = await findRecipientById(id); + if (!beforeUpdate) { + throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if ( + !placeIds.includes(beforeUpdate.placeOfWorshipId) || + (input.placeOfWorshipId && !placeIds.includes(input.placeOfWorshipId)) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang mengubah data penerima ini.", + }); + } + } + const updateData: Record = { ...data }; if (data.latitude !== undefined) updateData.latitude = data.latitude.toString(); if (data.longitude !== undefined) updateData.longitude = data.longitude.toString(); @@ -126,7 +198,7 @@ export const recipientRouter = createRouter({ if (updated) { await logActivitySafe({ - userId: beforeUpdate?.registeredBy ?? null, + userId: ctx.user.id, action: "UPDATE", entityType: "recipient", entityId: updated.id, @@ -137,7 +209,7 @@ export const recipientRouter = createRouter({ return updated; }), - verify: publicQuery + verify: authedQuery .input( z.object({ id: z.number(), @@ -146,7 +218,15 @@ export const recipientRouter = createRouter({ rejectionReason: z.string().optional(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + // Verify is only allowed for Admin and Officer + if (ctx.user.role !== "admin" && ctx.user.role !== "officer") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Hanya petugas verifikasi atau administrator yang dapat memverifikasi kelayakan.", + }); + } + const verified = await verifyRecipient(input.id, input.status, input.verifiedBy, input.rejectionReason); if (verified) { const statusText = input.status === "active" ? "Diterima" : "Ditolak"; @@ -161,15 +241,29 @@ export const recipientRouter = createRouter({ return verified; }), - delete: publicQuery + delete: authedQuery .input(z.object({ id: z.number() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const target = await findRecipientById(input.id); + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Penerima tidak ditemukan." }); + } + + if (ctx.user.role === "manager") { + const placeIds = await findPlaceIdsByManagerId(ctx.user.id); + if (!placeIds.includes(target.placeOfWorshipId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Anda tidak berwenang menghapus data penerima ini.", + }); + } + } + await deleteRecipient(input.id); if (target) { await logActivitySafe({ - userId: target.registeredBy ?? null, + userId: ctx.user.id, action: "DELETE", entityType: "recipient", entityId: target.id, diff --git a/api/userRouter.ts b/api/userRouter.ts index 128ac96..b679c21 100644 --- a/api/userRouter.ts +++ b/api/userRouter.ts @@ -10,6 +10,9 @@ import { } 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[0]) { try { @@ -31,6 +34,7 @@ export const userRouter = createRouter({ 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 }) => { @@ -53,6 +57,15 @@ export const userRouter = createRouter({ 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", @@ -72,6 +85,7 @@ export const userRouter = createRouter({ 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 }) => { @@ -96,6 +110,38 @@ export const userRouter = createRouter({ 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", diff --git a/src/pages/PlacesOfWorshipPage.tsx b/src/pages/PlacesOfWorshipPage.tsx index feea77c..9090a87 100644 --- a/src/pages/PlacesOfWorshipPage.tsx +++ b/src/pages/PlacesOfWorshipPage.tsx @@ -464,7 +464,7 @@ export default function PlacesOfWorshipPage() { {activeRecipients.map((recipient) => ( - {recipient.name} - {recipient.nik} + {recipient.name} - {recipient.phone || "-"} ))} diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 9f9251e..5461d85 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -24,6 +24,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; import { Shield, User, @@ -72,6 +73,7 @@ export default function UsersPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState<"admin" | "officer" | "manager">("manager"); + const [selectedPlaces, setSelectedPlaces] = useState([]); const [errorMsg, setErrorMsg] = useState(""); // tRPC Queries & Mutations @@ -79,6 +81,8 @@ export default function UsersPage() { 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!"); @@ -121,6 +125,7 @@ export default function UsersPage() { setEmail(""); setPassword(""); setRole("manager"); + setSelectedPlaces([]); setErrorMsg(""); setSelectedUser(null); }; @@ -137,6 +142,13 @@ export default function UsersPage() { 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); }; @@ -159,6 +171,11 @@ export default function UsersPage() { return; } + if (role === "manager" && selectedPlaces.length === 0) { + setErrorMsg("Harap pilih minimal satu rumah ibadah yang dikelola."); + return; + } + if (selectedUser) { updateUserMutation.mutate({ id: selectedUser.id, @@ -166,6 +183,7 @@ export default function UsersPage() { email, password: password ? password : undefined, role, + placeIds: role === "manager" ? selectedPlaces : undefined, }); } else { if (!password || password.length < 6) { @@ -177,6 +195,7 @@ export default function UsersPage() { email, password, role, + placeIds: role === "manager" ? selectedPlaces : undefined, }); } }; @@ -305,6 +324,15 @@ export default function UsersPage() { {ROLE_LABELS[u.role]} + {u.role === "manager" && ( +
+ Mengelola:{" "} + {(places ?? []) + .filter((p) => p.managerId === u.id) + .map((p) => p.name) + .join(", ") || "-"} +
+ )} {u.lastSignInAt @@ -487,6 +515,37 @@ export default function UsersPage() { + {role === "manager" && ( +
+ +
+ {placesLoading ? ( +
Memuat data...
+ ) : !places || places.length === 0 ? ( +
Belum ada data rumah ibadah.
+ ) : ( + places.map((place) => ( + + )) + )} +
+
+ )} +