diff --git a/app/(dash)/setting/parish/new/page.tsx b/app/(dash)/setting/parish/new/page.tsx new file mode 100644 index 0000000..7bb3b62 --- /dev/null +++ b/app/(dash)/setting/parish/new/page.tsx @@ -0,0 +1 @@ +export { default } from '@/pages/setting/ui/ParishCreatePage'; diff --git a/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx b/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx new file mode 100644 index 0000000..98bd708 --- /dev/null +++ b/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx @@ -0,0 +1,14 @@ +import { DioceseDetailPage } from "@/pages/setting/ui/DioceseDetailPage"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +/** + * Thin export for the Diocese Detail route. + * Follows the Domus architecture of keeping route files minimal. + */ +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/dash/app/(dash)/setting/diocese/new/page.tsx b/apps/dash/app/(dash)/setting/diocese/new/page.tsx new file mode 100644 index 0000000..eaa2d4b --- /dev/null +++ b/apps/dash/app/(dash)/setting/diocese/new/page.tsx @@ -0,0 +1 @@ +export { DioceseCreatePage as default } from "@/pages/setting/ui/DioceseCreatePage"; diff --git a/apps/dash/app/(dash)/setting/diocese/page.tsx b/apps/dash/app/(dash)/setting/diocese/page.tsx new file mode 100644 index 0000000..b8df0a6 --- /dev/null +++ b/apps/dash/app/(dash)/setting/diocese/page.tsx @@ -0,0 +1 @@ +export { default } from "@/pages/setting/ui/DioceseListPage"; diff --git a/apps/dash/app/(dash)/setting/parish/[id]/page.tsx b/apps/dash/app/(dash)/setting/parish/[id]/page.tsx new file mode 100644 index 0000000..a6dba15 --- /dev/null +++ b/apps/dash/app/(dash)/setting/parish/[id]/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import ParishDetailPage from "@/pages/setting/ui/ParishDetailPage"; + +export const metadata: Metadata = { + title: "Edit Parish | Domus", +}; + +export default ParishDetailPage; diff --git a/apps/dash/app/(dash)/setting/parish/new/page.tsx b/apps/dash/app/(dash)/setting/parish/new/page.tsx new file mode 100644 index 0000000..f9591d9 --- /dev/null +++ b/apps/dash/app/(dash)/setting/parish/new/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import ParishCreatePage from "@/pages/setting/ui/ParishCreatePage"; + +export const metadata: Metadata = { + title: "Add New Parish | Domus", +}; + +export default ParishCreatePage; diff --git a/apps/dash/app/(dash)/setting/parish/page.tsx b/apps/dash/app/(dash)/setting/parish/page.tsx new file mode 100644 index 0000000..29c4f92 --- /dev/null +++ b/apps/dash/app/(dash)/setting/parish/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; +import { ParishListPage } from "@/pages/setting/ui/ParishListPage"; + +export const metadata: Metadata = { + title: "Parish List | Domus", + description: "Manage parishes in the Domus system.", +}; + +export default ParishListPage; diff --git a/apps/dash/e2e/assets/.gitignore b/apps/dash/e2e/assets/.gitignore new file mode 100644 index 0000000..38acef9 --- /dev/null +++ b/apps/dash/e2e/assets/.gitignore @@ -0,0 +1 @@ +ktp.jpg diff --git a/apps/dash/e2e/features/org/join.spec.ts b/apps/dash/e2e/features/org/join.spec.ts index 10a1dde..e4e4573 100644 --- a/apps/dash/e2e/features/org/join.spec.ts +++ b/apps/dash/e2e/features/org/join.spec.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { AccountStatus } from "@domus/core"; import { expect, test } from "@playwright/test"; import { v7 } from "uuid"; @@ -124,50 +125,39 @@ test.describe("Organization Join Page", () => { test("should complete registration with ID card photo upload", async ({ context, }) => { - const TEST_JOIN_ID = `test-join-photo-${v7().substring(0, 8)}`; + const TEST_JOIN_ID = `test-join-photo-${v7()}`; await iHaveOrganizationWithJoinId(TEST_JOIN_ID); - const uniqueEmail = `toni-photo-${Date.now()}@tester.org`; + const uniqueEmail = `toni-photo-${v7()}@tester.org`; - // 1. Setup session for a new unique user await iHaveLoggedInAs(context, { name: "Toni Photo", email: uniqueEmail, accountStatus: AccountStatus.Pending, }); - // 2. Navigate to /join/[joinId] await joinPage.goto(TEST_JOIN_ID); - // 3. Fill basic info await joinPage.fillJoinForm({ title: "Prof.", birthPlace: "Barong Tongkok", birthDateDay: "1", }); - // 4. Upload ID card photo - await joinPage.uploadIdCardPhoto("e2e/assets/dummy-id.png"); + const testImagePath = path.join(__dirname, "../../assets/ktp.jpg"); + await joinPage.uploadIdCardPhoto(testImagePath); - // 5. Wait for upload to complete (Loading indicator disappears) - // We increase timeout here because actual upload to GDrive can be slow in some environments await expect(joinPage.page.getByText(/Uploading/i)).not.toBeVisible({ timeout: 45000, }); - // 6. Verify preview is visible with a generous timeout - const preview = joinPage.page.getByAltText(/Pratinjau KTP/i); - await expect(preview).toBeVisible({ timeout: 15000 }); + await expect(joinPage.idCardPreview).toBeVisible({ timeout: 30000 }); - // 7. Submit the form await joinPage.submit(); - // 8. Should redirect to success page await expect(joinPage.page).toHaveURL( new RegExp(`/join/${TEST_JOIN_ID}/success`), - { - timeout: 15000, - }, + { timeout: 15000 }, ); await expect(joinPage.successHeading).toBeVisible(); }); @@ -175,37 +165,26 @@ test.describe("Organization Join Page", () => { test("should allow removing uploaded ID card photo before submission", async ({ context, }) => { - const TEST_JOIN_ID = `test-join-remove-${v7().substring(0, 8)}`; + const TEST_JOIN_ID = `test-join-remove-${v7()}`; await iHaveOrganizationWithJoinId(TEST_JOIN_ID); - const uniqueEmail = `toni-remove-${Date.now()}@tester.org`; + const uniqueEmail = `toni-remove-${v7()}@tester.org`; - // 1. Setup session await iHaveLoggedInAs(context, { name: "Toni Remove", email: uniqueEmail, accountStatus: AccountStatus.Pending, }); - // 2. Navigate to /join/[joinId] await joinPage.goto(TEST_JOIN_ID); - // 3. Upload ID card photo - await joinPage.uploadIdCardPhoto("e2e/assets/dummy-id.png"); - - // 4. Wait for upload to complete (Loading indicator disappears) - await expect(joinPage.page.getByText(/Uploading/i)).not.toBeVisible({ - timeout: 30000, - }); - - // 5. Verify preview is visible - const preview = joinPage.page.getByAltText(/Pratinjau KTP/i); - await expect(preview).toBeVisible({ timeout: 10000 }); + const testImagePath = path.join(__dirname, "../../assets/ktp.jpg"); + await joinPage.uploadIdCardPhoto(testImagePath); - // 6. Click the remove button + // The button only appears after upload finishes (isUploadingPhoto becomes false) + await expect(joinPage.removePhotoButton).toBeVisible({ timeout: 45000 }); await joinPage.removePhotoButton.click(); - // 7. Verify preview is gone - await expect(preview).not.toBeVisible(); + await expect(joinPage.idCardPreview).toBeHidden(); }); }); diff --git a/apps/dash/e2e/features/setting/diocese/create.spec.ts b/apps/dash/e2e/features/setting/diocese/create.spec.ts new file mode 100644 index 0000000..a177e8f --- /dev/null +++ b/apps/dash/e2e/features/setting/diocese/create.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth"; +import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage"; + +test.describe("Diocese Page: Create", () => { + let createPage: DioceseCreatePage; + + test.beforeEach(async ({ page, context }) => { + createPage = new DioceseCreatePage(page); + // 1. Login as Super Admin to access setting + await iHaveLoggedInAsSuperAdmin(context); + }); + + test("should successfully create a new diocese with valid data", async ({ + page, + }) => { + await createPage.goto(); + await expect(page).toHaveURL(/\/setting\/diocese\/new$/); + + // 3. Verify page header + const heading = page.getByTestId("page-title"); + await expect(heading).toBeVisible({ timeout: 10000 }); + await expect(heading).toContainText(/Tambah Keuskupan|New Diocese/i); + + // 4. Fill and submit form + const dioceseName = `Keuskupan E2E ${Date.now()}`; + await createPage.fillForm({ + name: dioceseName, + description: "Automated E2E test diocese description.", + address: "123 Test St, Samarinda", + phone: "081234567890", + email: "e2e@domus.org", + website: "https://e2e-diocese.org", + }); + + await createPage.submit(); + + // 5. Verify redirection to detail page and appearance in list + await expect(page).toHaveURL(/\/setting\/diocese\/[a-zA-Z0-9-]+$/, { + timeout: 10000, + }); + + // Go back to list to verify it's there + await page.goto("/setting/diocese"); + await expect(page.getByText(dioceseName)).toBeVisible(); + }); + + test("should show validation errors when required fields are empty", async ({ + page, + }) => { + await createPage.goto(); + + // Focus, fill, clear, and blur name field to trigger validation + const nameInput = page.getByTestId("diocese-name-input"); + await nameInput.fill("test"); + await nameInput.fill(""); + await nameInput.blur(); + + await createPage.submit(); + + // Check for validation error on name + const nameError = page.locator( + "text=/wajib diisi|required|minimal 1|at least 1|too small/i", + ); + await expect(nameError.first()).toBeVisible(); + }); + + test("should navigate back when clicking cancel", async ({ page }) => { + // Start from list page to have history + await page.goto("/setting/diocese"); + await expect(page.getByTestId("add-diocese-btn")).toBeVisible(); + + // Click add button (from list page) + await page.getByTestId("add-diocese-btn").click(); + await expect(page).toHaveURL(/\/setting\/diocese\/new$/); + + // Click cancel in the form + await createPage.cancel(); + + // Should return to the list page via history.back() + await expect(page).toHaveURL(/\/setting\/diocese$/); + }); +}); diff --git a/apps/dash/e2e/features/setting/diocese/detail.spec.ts b/apps/dash/e2e/features/setting/diocese/detail.spec.ts new file mode 100644 index 0000000..d072550 --- /dev/null +++ b/apps/dash/e2e/features/setting/diocese/detail.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth"; +import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage"; +import { DioceseDetailPage } from "../../../pages/setting/DioceseDetailPage"; +import { DioceseListPage } from "../../../pages/setting/DioceseListPage"; + +test.describe("Diocese Page: Detail & Edit", () => { + let listPage: DioceseListPage; + let createPage: DioceseCreatePage; + let detailPage: DioceseDetailPage; + + test.beforeEach(async ({ page, context }) => { + listPage = new DioceseListPage(page); + createPage = new DioceseCreatePage(page); + detailPage = new DioceseDetailPage(page); + await iHaveLoggedInAsSuperAdmin(context); + }); + + test("should successfully update diocese information", async ({ page }) => { + // 1. Create a fresh diocese first to ensure we have something to edit + await createPage.goto(); + const uniqueId = Date.now(); + const originalName = `Diocese to Edit ${uniqueId}`; + await createPage.fillForm({ + name: originalName, + description: "Original description", + }); + await createPage.submit(); + await page.waitForURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/); + + // 2. Search for the diocese to ensure it's visible + await listPage.goto(); + await listPage.search(originalName); + + // Ensure only one card matches to avoid ambiguity + await expect(listPage.dioceseCards).toHaveCount(1); + const card = listPage.dioceseCards.first(); + await expect(card.getByTestId("diocese-details-btn")).toBeVisible(); + await card.getByTestId("diocese-details-btn").click(); + await expect(detailPage.headerTitle).toBeVisible(); + + // 3. Verify we are on the detail page + await expect(page).toHaveURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/); + await expect(detailPage.headerTitle).toContainText(originalName); + + // 4. Update the name and description + const updatedName = `Diocese Updated ${uniqueId}`; + await detailPage.fillForm({ + name: updatedName, + description: "Updated description through E2E", + }); + await detailPage.submit(); + + // 5. Verify toast and header update (header should update after refresh) + await expect( + page.getByText(/berhasil diperbarui|successfully updated/i), + ).toBeVisible(); + await expect(detailPage.headerTitle).toContainText(updatedName); + + // 6. Go back to list and verify updated name + await page.goto("/setting/diocese"); + await expect(page.getByText(updatedName)).toBeVisible(); + await expect(page.getByText(originalName)).not.toBeVisible(); + }); + + test("should successfully soft-delete a diocese", async ({ page }) => { + // 1. Create a fresh diocese to delete + await createPage.goto(); + await page.waitForLoadState("networkidle"); + const uniqueId = Date.now(); + const nameToDelete = `Diocese to Delete ${uniqueId}`; + await createPage.fillForm({ + name: nameToDelete, + }); + await createPage.submit(); + await page.waitForURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/); + + // 2. Search for the diocese to delete + await listPage.goto(); + await listPage.search(nameToDelete); + + // Ensure only one card matches to avoid ambiguity + await expect(listPage.dioceseCards).toHaveCount(1); + const card = listPage.dioceseCards.first(); + await expect(card.getByTestId("diocese-details-btn")).toBeVisible(); + await card.getByTestId("diocese-details-btn").click(); + await expect(detailPage.headerTitle).toBeVisible(); + + // 3. Perform delete with confirmation + await detailPage.deleteAndConfirm(); + + // 4. Verify success toast and redirection to list + await expect( + page.getByText(/berhasil dihapus|successfully deleted/i), + ).toBeVisible(); + await expect(page).toHaveURL(/\/setting\/diocese$/); + + // 5. Verify it's no longer in the list (Soft Delete) + await expect(page.getByText(nameToDelete)).not.toBeVisible(); + }); +}); diff --git a/apps/dash/e2e/features/setting/diocese/list.spec.ts b/apps/dash/e2e/features/setting/diocese/list.spec.ts new file mode 100644 index 0000000..00c24a1 --- /dev/null +++ b/apps/dash/e2e/features/setting/diocese/list.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth"; +import { iHaveDiocese } from "../../../helper/diocese"; +import { DioceseListPage } from "../../../pages/setting/DioceseListPage"; + +test.describe("Diocese Page: Browsing and Search", () => { + let listPage: DioceseListPage; + + test.beforeEach(async ({ page, context }) => { + listPage = new DioceseListPage(page); + // Setup session and data + await iHaveLoggedInAsSuperAdmin(context); + await iHaveDiocese({ name: "Keuskupan Agung Samarinda" }); + }); + + test("should render the diocese list and search dynamically", async ({ + page, + }) => { + // 1. Navigate to the diocese route + await listPage.goto(); + + // 2. Verify page content + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading).toBeVisible(); + + // 3. Verify diocese cards are rendered + const count = await listPage.getVisibleDioceseCount(); + expect(count).toBeGreaterThan(0); + + // 4. Verify search functionality dynamically + // Pick a card that is currently visible + const firstCard = listPage.getDioceseCardAt(0); + const cardName = await firstCard + .getByTestId("diocese-card-name") + .textContent(); + + if (cardName) { + await listPage.search(cardName); + + // Verify the searched card is visible and others are filtered + await expect(listPage.dioceseGrid).toContainText([cardName]); + + // Verify search clear button is visible when searching + await expect(listPage.searchClearBtn).toBeVisible(); + + // Clear search and verify list is restored + await listPage.clearSearch(); + await expect(listPage.getVisibleDioceseCount()).resolves.toBeGreaterThan( + 0, + ); + } + }); + + test("should show an empty state for non-matching searches", async () => { + await listPage.goto(); + + // Search for something impossible + await listPage.search("XYZ-NON-EXISTENT-DIOCESE-999-POTATO"); + + // Verify empty state UI + await expect(listPage.emptyState).toBeVisible(); + await expect( + listPage.page.getByText( + /No dioceses found|Tidak ada keuskupan ditemukan/i, + ), + ).toBeVisible(); + await expect(listPage.dioceseCards).toHaveCount(0); + }); +}); diff --git a/apps/dash/e2e/features/setting/diocese/rbac.spec.ts b/apps/dash/e2e/features/setting/diocese/rbac.spec.ts new file mode 100644 index 0000000..b258b76 --- /dev/null +++ b/apps/dash/e2e/features/setting/diocese/rbac.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; +import { + iHaveLoggedInAsApprovedParishioner, + iHaveLoggedInAsSuperAdmin, +} from "../../../helper/auth"; +import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage"; +import { DioceseDetailPage } from "../../../pages/setting/DioceseDetailPage"; +import { DioceseListPage } from "../../../pages/setting/DioceseListPage"; + +test.describe("Diocese Page: RBAC Enforcement", () => { + let createPage: DioceseCreatePage; + + test.beforeEach(async ({ page }) => { + createPage = new DioceseCreatePage(page); + }); + + test("Approved Parishioner should have read-only access", async ({ + browser, + context, + }) => { + // 1. Setup: Create a diocese as Admin first so there's something to view + await iHaveLoggedInAsSuperAdmin(context); + const uniqueId = Date.now(); + const dioceseName = `RBAC Test Diocese ${uniqueId}`; + await createPage.goto(); + await createPage.fillForm({ name: dioceseName }); + await createPage.submit(); + + // 2. Login as Approved Parishioner in a FRESH context to ensure isolation + const parishionerContext = await browser.newContext(); + const parishionerPage = await parishionerContext.newPage(); + const parishionerListPage = new DioceseListPage(parishionerPage); + const parishionerCreatePage = new DioceseCreatePage(parishionerPage); + const parishionerDetailPage = new DioceseDetailPage(parishionerPage); + + await iHaveLoggedInAsApprovedParishioner(parishionerContext, { + email: `user-${uniqueId}@example.com`, + name: "Approved User", + }); + + // 3. Verify List Page: Add button should NOT be visible + await parishionerListPage.goto(); + await expect(parishionerListPage.addDioceseBtn).toBeHidden(); + await expect(parishionerPage.getByText(dioceseName)).toBeVisible(); + + // 4. Verify Create Page: Should show unauthorized message + await parishionerCreatePage.goto(); + await expect( + parishionerPage.getByText(/Unauthorized access/i), + ).toBeVisible(); + await expect(parishionerPage.getByTestId("diocese-form")).toBeHidden(); + + // 5. Verify Detail Page: Save and Delete buttons should NOT be visible + await parishionerListPage.goto(); + await parishionerListPage.search(dioceseName); + await expect(parishionerListPage.dioceseCards).toHaveCount(1); + const card = parishionerListPage.dioceseCards.first(); + await card.getByTestId("diocese-details-btn").click({ force: true }); + await expect(parishionerDetailPage.headerTitle).toBeVisible(); + + await expect(parishionerDetailPage.submitBtn).toBeHidden(); + await expect(parishionerDetailPage.deleteBtn).toBeHidden(); + + // Verify fields are disabled (read-only) + await expect(parishionerDetailPage.nameInput).toBeDisabled(); + + await parishionerContext.close(); + }); +}); diff --git a/apps/dash/e2e/features/setting/parish.spec.ts b/apps/dash/e2e/features/setting/parish.spec.ts new file mode 100644 index 0000000..85e638f --- /dev/null +++ b/apps/dash/e2e/features/setting/parish.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin, iHaveParish } from "../../helper"; +import { ParishListPage } from "../../pages/setting/ParishListPage"; + +test.describe("Parish List Feature", () => { + let parishPage: ParishListPage; + + test.beforeEach(async ({ page }) => { + await iHaveLoggedInAsSuperAdmin(page.context()); + // Ensure at least one parish exists for listing tests + await iHaveParish({ name: "Katedral Santo Yosef" }); + + parishPage = new ParishListPage(page); + await parishPage.goto(); + }); + + test("should display the parish list page with premium elements", async () => { + await expect(parishPage.premiumHero).toBeVisible(); + await expect(parishPage.searchInput).toBeVisible(); + await expect(parishPage.parishGrid).toBeVisible(); + }); + + test("should be able to search for a parish by name", async () => { + // Assuming 'Katedral' exists in the seeded/mocked data + const query = "Katedral"; + await parishPage.search(query); + + const count = await parishPage.getVisibleParishCount(); + if (count > 0) { + const firstCard = parishPage.getParishCardAt(0); + await expect(firstCard).toContainText(query); + } else { + await expect(parishPage.emptyState).toBeVisible(); + } + }); + + test("should display empty state when no results are found", async () => { + await parishPage.search("NonExistentParish12345"); + await expect(parishPage.emptyState).toBeVisible(); + await expect(parishPage.parishCards).toHaveCount(0); + }); + + test("should clear search and restore list", async () => { + await parishPage.search("Katedral"); + await parishPage.clearSearch(); + await expect(parishPage.searchInput).toHaveValue(""); + await expect(parishPage.parishCards.first()).toBeVisible(); + }); + + test("should navigate to parish detail when clicking a card", async ({ + page, + }) => { + const count = await parishPage.getVisibleParishCount(); + test.skip(count === 0, "No parishes to test navigation"); + + const firstCard = parishPage.getParishCardAt(0); + await firstCard.click(); + + // Check if URL matches the detail pattern + await expect(page).toHaveURL(/\/setting\/parish\/[0-9a-f-]+/); + }); +}); diff --git a/apps/dash/e2e/features/setting/parish/create.spec.ts b/apps/dash/e2e/features/setting/parish/create.spec.ts new file mode 100644 index 0000000..b4049fc --- /dev/null +++ b/apps/dash/e2e/features/setting/parish/create.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth"; +import { iHaveVicariate } from "../../../helper/vicariate"; +import { ParishCreatePage } from "../../../pages/setting/ParishCreatePage"; + +test.describe("Parish Creation Feature", () => { + let createPage: ParishCreatePage; + + test.beforeEach(async ({ page }) => { + await iHaveLoggedInAsSuperAdmin(page.context()); + + // Ensure we have a setup for chained select + await iHaveVicariate({ + name: "Dekenat Mahakam Ulu", + }); + + createPage = new ParishCreatePage(page); + await createPage.goto(); + }); + + test("should display the creation form with premium breadcrumbs", async ({ + page, + }) => { + await expect(page.getByText(/create parish|tambah paroki/i)).toBeVisible(); + await expect(createPage.dioceseSelect).toBeVisible(); + await expect(createPage.nameInput).toBeVisible(); + }); + + test("should permit creating a parish via chained selection", async ({ + page, + }) => { + const parishName = `Parish Test ${Date.now()}`; + + await createPage.fillForm({ + dioceseName: "Keuskupan Agung Samarinda", + vicariateName: "Dekenat Mahakam Ulu", + name: parishName, + address: "Jl. Test No. 123", + phone: "08123456789", + email: "test@parish.com", + description: "Testing automated parish creation", + }); + + await createPage.submit(); + + // Verify success redirect and list presence + await expect(page).toHaveURL(/\/setting\/parish$/); + await expect(page.getByText(/success|berhasil|sukses/i)).toBeVisible(); + await expect(page.getByText(parishName)).toBeVisible(); + }); + + test("should show validation errors when fields are empty", async ({ + page, + }) => { + await createPage.submit(); + + // Ensure validation errors appear in the DOM (searching specifically for Zod patterns) + await expect( + page + .locator("p") + .filter({ hasText: /too small|invalid/i }) + .first(), + ).toBeVisible({ timeout: 10000 }); + + // Check multiple identical errors don't crash UI (our key fix) + const _errorMessages = page.locator( + 'p:text-is("Required"), p:text-is("Wajib diisi")', + ); + // If multiple fields are required, we might see multiple "Required" or similar. + // Our fix ensures they have unique keys and handle collisions. + }); +}); diff --git a/apps/dash/e2e/features/setting/parish/detail.spec.ts b/apps/dash/e2e/features/setting/parish/detail.spec.ts new file mode 100644 index 0000000..096c421 --- /dev/null +++ b/apps/dash/e2e/features/setting/parish/detail.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth"; +import { iHaveParish } from "../../../helper/parish"; +import { iHaveVicariate } from "../../../helper/vicariate"; +import { ParishDetailPage } from "../../../pages/setting/ParishDetailPage"; + +test.describe("Parish Detail & Update Feature", () => { + let detailPage: ParishDetailPage; + let testParish: any; + + test.beforeEach(async ({ page }) => { + await iHaveLoggedInAsSuperAdmin(page.context()); + + // Seed data: Diocese -> Vicariate -> Parish + const vicariate = await iHaveVicariate({ + name: "Dekenat Sendawar", + }); + + testParish = await iHaveParish({ + name: `Test Parish ${Date.now()}`, + vicariateId: vicariate.id, + }); + + detailPage = new ParishDetailPage(page); + await detailPage.goto(testParish.id); + }); + + test("should display correctly populated parish details", async ({ + page, + }) => { + await expect(detailPage.nameInput).toHaveValue(testParish.name); + await expect(page.getByText(/detail paroki|parish detail/i)).toBeVisible(); + }); + + test("should update parish name successfully", async ({ page }) => { + const newName = `${testParish.name} Updated`; + + await detailPage.fillForm({ name: newName }); + await detailPage.submit(); + + // Verify toast and value persistence + await expect(page.getByText(/success|berhasil|sukses/i)).toBeVisible(); + await expect(detailPage.nameInput).toHaveValue(newName); + + // Refresh to ensure server side update + await page.reload(); + await expect(detailPage.nameInput).toHaveValue(newName); + }); + + test("should show validation error for empty name", async ({ page }) => { + // Clear and blur to trigger validation + await detailPage.nameInput.fill(""); + await detailPage.nameInput.click(); // Ensure focus + await detailPage.nameInput.blur(); + await detailPage.submit(); + + // Check for ANY destructive text appearing (the validation error color) + const errorMsg = page.locator(".text-destructive").first(); + await expect(errorMsg).toBeVisible({ timeout: 15000 }); + }); + + test("should delete parish via confirmation dialog", async ({ page }) => { + // Navigate to page first (ensure we are on the right page) + await detailPage.goto(testParish.id); + + await detailPage.delete(); + + // Verify redirect to list and success toast + await expect(page).toHaveURL(/\/setting\/parish$/); + await expect( + page.getByText(/success|berhasil|sukses/i).first(), + ).toBeVisible(); + + // Refresh to ensure it's gone from server side + await page.reload(); + await expect(page.getByText(testParish.name)).not.toBeVisible(); + }); +}); diff --git a/apps/dash/e2e/features/setting/parish/rbac.spec.ts b/apps/dash/e2e/features/setting/parish/rbac.spec.ts new file mode 100644 index 0000000..df41ba8 --- /dev/null +++ b/apps/dash/e2e/features/setting/parish/rbac.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { + iHaveLoggedInAsApprovedParishioner, + iHaveLoggedInAsSuperAdmin, +} from "../../../helper/auth"; +import { iHaveVicariate } from "../../../helper/vicariate"; +import { ParishCreatePage } from "../../../pages/setting/ParishCreatePage"; +import { ParishDetailPage } from "../../../pages/setting/ParishDetailPage"; +import { ParishListPage } from "../../../pages/setting/ParishListPage"; + +test.describe("Parish Page: RBAC Enforcement", () => { + let createPage: ParishCreatePage; + + test.beforeEach(async ({ page }) => { + createPage = new ParishCreatePage(page); + }); + + test("Approved Parishioner should have read-only access", async ({ + browser, + context, + }) => { + // 1. Setup: Create a parish as Admin first so there's something to view + const _adminUser = await iHaveLoggedInAsSuperAdmin(context); + const uniqueId = Date.now(); + const parishName = `RBAC Test Parish ${uniqueId}`; + + // Ensure we have a vicariate for the parish + await iHaveVicariate({ + name: "Dekenat Mahakam Ulu", + }); + + await createPage.goto(); + await createPage.fillForm({ + dioceseName: "Keuskupan Agung Samarinda", + vicariateName: "Dekenat Mahakam Ulu", + name: parishName, + address: "Jl. RBAC No. 1", + }); + await createPage.submit(); + + // 2. Login as Approved Parishioner in a FRESH context to ensure isolation + const parishionerContext = await browser.newContext(); + const parishionerPage = await parishionerContext.newPage(); + const parishionerListPage = new ParishListPage(parishionerPage); + const parishionerCreatePage = new ParishCreatePage(parishionerPage); + const parishionerDetailPage = new ParishDetailPage(parishionerPage); + + await iHaveLoggedInAsApprovedParishioner(parishionerContext, { + email: `user-${uniqueId}@example.com`, + name: "Approved User", + }); + + // 3. Verify List Page: Add button should NOT be visible + await parishionerListPage.goto(); + await expect(parishionerListPage.addParishBtn).toBeHidden(); + await expect(parishionerPage.getByText(parishName)).toBeVisible(); + + // 4. Verify Create Page: Should show unauthorized message or be redirected + await parishionerCreatePage.goto(); + // We expect an unauthorized message OR a redirect back to list/home + // The diocese test expected "Unauthorized access" + await expect( + parishionerPage.getByText(/Unauthorized access|tidak memiliki akses/i), + ).toBeVisible(); + await expect(parishionerPage.getByTestId("parish-form")).toBeHidden(); + + // 5. Verify Detail Page: Save and Delete buttons should NOT be visible + await parishionerListPage.goto(); + await parishionerListPage.search(parishName); + const card = parishionerListPage.getParishCardByName(parishName); + await card.click(); // Click the card to go to detail + + await expect(parishionerDetailPage.nameInput).toBeVisible(); + await expect(parishionerDetailPage.submitBtn).toBeHidden(); + await expect(parishionerDetailPage.deleteBtn).toBeHidden(); + + // Verify fields are disabled (read-only) + await expect(parishionerDetailPage.nameInput).toBeDisabled(); + + await parishionerContext.close(); + }); +}); diff --git a/apps/dash/e2e/global-setup.ts b/apps/dash/e2e/global-setup.ts new file mode 100644 index 0000000..2249725 --- /dev/null +++ b/apps/dash/e2e/global-setup.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import https from "node:https"; +import path from "node:path"; +import type { FullConfig } from "@playwright/test"; + +async function globalSetup(_config: FullConfig) { + const assetsDir = path.resolve(__dirname, "assets"); + const ktpPath = path.join(assetsDir, "ktp.jpg"); + const ktpUrl = "https://s3-dev.pkrbt.id/mock/ktp.jpg"; + + // Ensure assets directory exists + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + + // Download KTP if not exists + if (!fs.existsSync(ktpPath)) { + console.log("Downloading mock KTP image..."); + await new Promise((resolve, reject) => { + const file = fs.createWriteStream(ktpPath); + https + .get(ktpUrl, (response) => { + if (response.statusCode !== 200) { + reject( + new Error(`Failed to download image: ${response.statusCode}`), + ); + return; + } + response.pipe(file); + file.on("finish", () => { + file.close(); + console.log("Download complete:", ktpPath); + resolve(true); + }); + }) + .on("error", (err) => { + fs.unlink(ktpPath, () => {}); + reject(err); + }); + }); + } +} + +export default globalSetup; diff --git a/apps/dash/e2e/global-teardown.ts b/apps/dash/e2e/global-teardown.ts new file mode 100644 index 0000000..e6e115b --- /dev/null +++ b/apps/dash/e2e/global-teardown.ts @@ -0,0 +1,25 @@ +import { truncateAllTables } from "@domus/db"; + +/** + * Global teardown script for Playwright E2E tests. + * Cleans up the database state after all tests have completed. + */ +async function globalTeardown() { + // We only run this if NEXT_PUBLIC_E2E_MOCK is true to avoid accidental data loss + // in non-E2E environments, although truncateAllTables has its own protections. + if (process.env.NEXT_PUBLIC_E2E_MOCK === "true") { + console.log("๐ŸŽฌ Global Teardown: Starting database cleanup..."); + try { + await truncateAllTables(); + console.log("โœ… Global Teardown: Cleanup finished successfully."); + } catch (error) { + console.error("โŒ Global Teardown: Database cleanup failed!", error); + } + } else { + console.log( + "โญ๏ธ Global Teardown: Skipping database cleanup (E2E Mock mode not active).", + ); + } +} + +export default globalTeardown; diff --git a/apps/dash/e2e/helper/auth.ts b/apps/dash/e2e/helper/auth.ts index f14c004..9a39ff0 100644 --- a/apps/dash/e2e/helper/auth.ts +++ b/apps/dash/e2e/helper/auth.ts @@ -22,6 +22,25 @@ export async function iHaveLoggedInAsSuperAdmin( return iHaveLoggedInAs(context, withDefaults); } +/** + * Orchestrates a complete login flow for an approved ordinary user (Parishioner). + */ +export async function iHaveLoggedInAsApprovedParishioner( + context: BrowserContext, + payload?: Partial, +) { + const withDefaults: UserPayload = { + email: "parishioner@example.com", + name: "Regular Parishioner", + password: "testing123", + accountStatus: AccountStatus.Approved, + role: [UserRole.Parishioner], + ...payload, + }; + + return iHaveLoggedInAs(context, withDefaults); +} + /** * Orchestrates an E2E login flow using the UI. */ diff --git a/apps/dash/e2e/helper/diocese.ts b/apps/dash/e2e/helper/diocese.ts new file mode 100644 index 0000000..d91cf61 --- /dev/null +++ b/apps/dash/e2e/helper/diocese.ts @@ -0,0 +1,57 @@ +import type { AuthContext, CreateDiocese } from "@domus/core"; +import * as core from "../../src/shared/core/service"; + +/** + * Payload for creating a diocese in tests. + */ +export type DiocesePayload = Partial & { + name: string; +}; + +/** + * Ensures a diocese exists with the given payload. + * Priotizes finding by name. + */ +export async function iHaveDiocese(payload: DiocesePayload) { + const withDefaults: CreateDiocese = { + description: "Test Diocese Description", + address: "Test Address", + phone: "08123456789", + email: "test@diocese.com", + website: "https://diocese.com", + ...payload, + }; + + // 1. Try Find by Name + if (!core.diocese) { + throw new Error("Diocese service is undefined in iHaveDiocese helper"); + } + + const [dioceses] = await core.diocese.findAll( + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + withDefaults.name, + ); + + let diocese = dioceses?.find((d) => d.name === withDefaults.name); + + // 2. Create if not exists + if (!diocese) { + const [created, error] = await core.diocese.create( + withDefaults, + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + ); + + if (error) throw error; + diocese = created; + } + + return diocese; +} diff --git a/apps/dash/e2e/helper/index.ts b/apps/dash/e2e/helper/index.ts index 5e1b3ab..1a6191b 100644 --- a/apps/dash/e2e/helper/index.ts +++ b/apps/dash/e2e/helper/index.ts @@ -1,2 +1,6 @@ export * from "./auth"; +export * from "./diocese"; +export * from "./org"; +export * from "./parish"; export * from "./user"; +export * from "./vicariate"; diff --git a/apps/dash/e2e/helper/parish.ts b/apps/dash/e2e/helper/parish.ts new file mode 100644 index 0000000..bcec042 --- /dev/null +++ b/apps/dash/e2e/helper/parish.ts @@ -0,0 +1,62 @@ +import type { AuthContext, CreateParish } from "@domus/core"; +import * as core from "../../src/shared/core/service"; +import { iHaveVicariate } from "./vicariate"; + +/** + * Payload for creating a parish in tests. + */ +export type ParishPayload = Partial & { + name: string; +}; + +/** + * Ensures a parish exists with the given payload. + * Prioritizes finding by name. + */ +export async function iHaveParish(payload: ParishPayload) { + // 1. Ensure vicariate exists + const vicariate = await iHaveVicariate({ name: "Kutai Barat" }); + + const withDefaults: CreateParish = { + vicariateId: vicariate.id, + address: "Test Parish Address", + phone: "08123456789", + email: "parish@test.com", + website: "https://test-parish.com", + logo: "https://s3.pkrbt.id/static/cathedral/1.webp", + ...payload, + }; + + // 1. Try Find by Name + if (!core.parish) { + throw new Error("Parish service is undefined in iHaveParish helper"); + } + + const [parishes] = await core.parish.findAll( + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + withDefaults.name, + ); + + let parish = parishes?.find((p) => p.name === withDefaults.name); + + // 2. Create if not exists + if (!parish) { + const [created, error] = await core.parish.create( + withDefaults, + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + ); + + if (error) throw error; + parish = created; + } + + return parish; +} diff --git a/apps/dash/e2e/helper/user.ts b/apps/dash/e2e/helper/user.ts index b992cb5..3602ffe 100644 --- a/apps/dash/e2e/helper/user.ts +++ b/apps/dash/e2e/helper/user.ts @@ -1,7 +1,7 @@ import { AccountStatus, UserRole } from "@domus/core"; import { v7 } from "uuid"; import auth from "@/shared/auth/server"; -import service from "@/shared/core"; +import * as core from "@/shared/core/service"; export type UserPayload = { id?: string; @@ -29,40 +29,57 @@ export async function iHaveUser(payload: UserPayload) { // 1. Try Find by ID if (withDefaults.id) { - const [found] = await service.user.findById(withDefaults.id); + if (!core.user) { + throw new Error("User service is undefined in iHaveUser helper"); + } + const [found] = await core.user.findById(withDefaults.id); if (found) user = found; } // 2. Try Find by Email if still not found if (!user) { - const [found] = await service.user.findByEmail(withDefaults.email); + const [found] = await core.user.findByEmail(withDefaults.email); if (found) user = found; } // 3. Create if not exists if (!user) { - const res = await auth.api.signUpEmail({ - body: { - email: withDefaults.email, - password: withDefaults.password, - name: withDefaults.name, - }, - }); + try { + const res = await auth.api.signUpEmail({ + body: { + email: withDefaults.email, + password: withDefaults.password, + name: withDefaults.name, + }, + }); - if (!res?.user) { - throw new Error( - `Failed to create user via signUpEmail: ${withDefaults.email}`, - ); - } + if (!res?.user) { + throw new Error( + `Failed to create user via signUpEmail: ${withDefaults.email}`, + ); + } - const [d, e] = await service.user.findById(res.user.id); - if (e) throw e; - user = d; + const [d, e] = await core.user.findById(res.user.id); + if (e) throw e; + user = d; + } catch (err: any) { + // Handle race condition: if user was created by another worker between our check and signUpEmail + if (err.code === "23505" || err.message?.includes("already exists")) { + const [found] = await core.user.findByEmail(withDefaults.email); + if (found) { + user = found; + } else { + throw err; + } + } else { + throw err; + } + } } // 4. Update existing user to match payload (direct DB update) const { name, email, role, accountStatus } = withDefaults; - const [updatedUser, updateError] = await service.user.update(user.id, { + const [updatedUser, updateError] = await core.user.update(user.id, { name, email, role, diff --git a/apps/dash/e2e/helper/vicariate.ts b/apps/dash/e2e/helper/vicariate.ts new file mode 100644 index 0000000..5549f5d --- /dev/null +++ b/apps/dash/e2e/helper/vicariate.ts @@ -0,0 +1,59 @@ +import type { AuthContext, CreateVicariate } from "@domus/core"; +import * as core from "../../src/shared/core/service"; +import { iHaveDiocese } from "./diocese"; + +/** + * Payload for creating a vicariate in tests. + */ +export type VicariatePayload = Partial & { + name: string; +}; + +/** + * Ensures a vicariate exists with the given payload. + * Prioritizes finding by name. + */ +export async function iHaveVicariate(payload: VicariatePayload) { + // 1. Ensure diocese exists + const diocese = await iHaveDiocese({ name: "Keuskupan Agung Samarinda" }); + + const withDefaults: CreateVicariate = { + dioceseId: diocese.id, + address: "Test Vicariate Address", + phone: "08123456789", + email: "vicariate@test.com", + ...payload, + }; + + // 2. Try Find by Name + if (!core.vicariate) { + throw new Error("Vicariate service is undefined in iHaveVicariate helper"); + } + + const [vicariates] = await core.vicariate.findAll( + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + ); + + let vicariate = vicariates?.find((v) => v.name === withDefaults.name); + + // 3. Create if not exists + if (!vicariate) { + const [created, error] = await core.vicariate.create( + withDefaults, + { + userId: "system", + roles: ["super-admin"], + accountStatus: "approved", + } as AuthContext, // Bypass auth check for seeding + ); + + if (error) throw error; + vicariate = created; + } + + return vicariate; +} diff --git a/apps/dash/e2e/pages/org/OrgJoinPage.ts b/apps/dash/e2e/pages/org/OrgJoinPage.ts index 0e70b22..94126dc 100644 --- a/apps/dash/e2e/pages/org/OrgJoinPage.ts +++ b/apps/dash/e2e/pages/org/OrgJoinPage.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; /** * Page Object Model for the Organization Join page. @@ -15,9 +15,10 @@ export class OrgJoinPage { readonly birthDateTrigger: Locator; readonly idCardInput: Locator; readonly submitButton: Locator; + readonly removePhotoButton: Locator; + readonly idCardPreview: Locator; // Success/Error states readonly successHeading: Locator; - readonly removePhotoButton: Locator; readonly alreadyExistsHeading: Locator; readonly invalidHeading: Locator; @@ -29,10 +30,13 @@ export class OrgJoinPage { this.birthPlaceInput = page.getByLabel(/Tempat Lahir/i); this.maleRadio = page.getByText(/Laki-laki/i); this.femaleRadio = page.getByText(/Perempuan/i); - this.birthDateTrigger = page.locator("#birthDate"); - this.idCardInput = page.locator('input[type="file"]'); - this.removePhotoButton = page.locator("#remove-photo-button"); - this.submitButton = page.locator("#submit-registration-button"); + this.birthDateTrigger = page.getByTestId("birth-date-trigger"); + this.idCardInput = page.getByTestId("id-card-input"); + this.idCardPreview = page.getByTestId("ktp-preview-image"); + this.removePhotoButton = page.getByTestId("remove-photo-button"); + this.submitButton = page.getByRole("button").filter({ + hasText: /Bergabung Sekarang|Perbarui Pendaftaran/i, + }); this.successHeading = page.getByRole("heading", { name: /Pendaftaran Berhasil!/i, @@ -68,18 +72,29 @@ export class OrgJoinPage { await this.birthPlaceInput.fill(data.birthPlace); await this.maleRadio.click(); await this.birthDateTrigger.click(); - // Simplified date selection matching the spec - await this.page - .getByRole("gridcell", { name: data.birthDateDay }) + + // Wait for calendar to be visible + const calendar = this.page.locator('[data-slot="calendar"]'); + await expect(calendar).toBeVisible({ timeout: 15000 }); + + // Target the specific day button inside the calendar + // Use a precise regex for the day number to avoid partial matches (e.g., "1" matching "10") + const dayRegex = new RegExp(`^${data.birthDateDay}$`); + await calendar + .getByRole("button") + .filter({ hasText: dayRegex }) + .filter({ + hasNot: this.page.locator('[class*="outside"], [class*="rdp-outside"]'), + }) .first() .click(); } /** - * Clicks the 'Bergabung Sekarang' button. + * Clicks the 'Bergabung Sekarang' (or Update) button. */ async submit() { - await this.submitButton.click(); + await this.submitButton.first().click(); } /** @@ -88,6 +103,8 @@ export class OrgJoinPage { * @param filePath - The local path to the file to upload. */ async uploadIdCardPhoto(filePath: string) { + // Ensure the input is ready + await this.idCardInput.waitFor({ state: "attached" }); await this.idCardInput.setInputFiles(filePath); } } diff --git a/apps/dash/e2e/pages/setting/DioceseCreatePage.ts b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts new file mode 100644 index 0000000..3509678 --- /dev/null +++ b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts @@ -0,0 +1,77 @@ +import type { Locator, Page } from "@playwright/test"; + +/** + * Page Object Model for the Diocese Create page. + * Encapsulates selectors and actions for creating a new diocese. + */ +export class DioceseCreatePage { + readonly page: Page; + readonly nameInput: Locator; + readonly addressInput: Locator; + readonly phoneInput: Locator; + readonly emailInput: Locator; + readonly websiteInput: Locator; + readonly logoInput: Locator; + readonly descInput: Locator; + readonly submitBtn: Locator; + readonly cancelBtn: Locator; + readonly headerTitle: Locator; + + constructor(page: Page) { + this.page = page; + this.nameInput = page.getByTestId("diocese-name-input"); + this.logoInput = page.getByTestId("diocese-logo-input"); + this.descInput = page.getByTestId("diocese-desc-input"); + this.addressInput = page.getByTestId("diocese-address-input"); + this.phoneInput = page.getByTestId("diocese-phone-input"); + this.emailInput = page.getByTestId("diocese-email-input"); + this.websiteInput = page.getByTestId("diocese-website-input"); + this.submitBtn = page.getByTestId("save-diocese-btn"); + this.cancelBtn = page.getByRole("button", { name: /Cancel|Batal/i }); + this.headerTitle = page.getByTestId("page-title"); + } + + /** + * Navigates to the diocese creation page. + */ + async goto() { + await this.page.goto("/setting/diocese/new"); + } + + /** + * Fills the diocese creation form with the provided data. + * + * @param data - The diocese data. + */ + async fillForm(data: { + name: string; + address?: string; + phone?: string; + email?: string; + website?: string; + logo?: string; + description?: string; + }) { + await this.nameInput.fill(data.name); + if (data.logo) await this.logoInput.fill(data.logo); + if (data.description) await this.descInput.fill(data.description); + if (data.address) await this.addressInput.fill(data.address); + if (data.phone) await this.phoneInput.fill(data.phone); + if (data.email) await this.emailInput.fill(data.email); + if (data.website) await this.websiteInput.fill(data.website); + } + + /** + * Submits the diocese creation form. + */ + async submit() { + await this.submitBtn.click(); + } + + /** + * Cancels the diocese creation. + */ + async cancel() { + await this.cancelBtn.click(); + } +} diff --git a/apps/dash/e2e/pages/setting/DioceseDetailPage.ts b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts new file mode 100644 index 0000000..c5d46aa --- /dev/null +++ b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts @@ -0,0 +1,85 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Page Object Model for the Diocese Detail page. + * Encapsulates selectors and actions for updating and deleting a diocese. + */ +export class DioceseDetailPage { + readonly page: Page; + readonly nameInput: Locator; + readonly addressInput: Locator; + readonly phoneInput: Locator; + readonly emailInput: Locator; + readonly websiteInput: Locator; + readonly logoInput: Locator; + readonly descInput: Locator; + readonly submitBtn: Locator; + readonly cancelBtn: Locator; + readonly deleteBtn: Locator; + readonly confirmDeleteBtn: Locator; + readonly headerTitle: Locator; + + constructor(page: Page) { + this.page = page; + this.nameInput = page.getByTestId("diocese-name-input"); + this.logoInput = page.getByTestId("diocese-logo-input"); + this.descInput = page.getByTestId("diocese-desc-input"); + this.addressInput = page.getByTestId("diocese-address-input"); + this.phoneInput = page.getByTestId("diocese-phone-input"); + this.emailInput = page.getByTestId("diocese-email-input"); + this.websiteInput = page.getByTestId("diocese-website-input"); + this.submitBtn = page.getByTestId("save-diocese-btn"); + this.cancelBtn = page.getByRole("button", { name: /Cancel|Batal/i }); + this.deleteBtn = page.getByTestId("delete-diocese-btn"); + this.confirmDeleteBtn = page + .getByRole("button", { name: /Hapus/i }) + .filter({ hasText: /^Hapus$/ }); + this.headerTitle = page.getByTestId("page-title"); + } + + /** + * Navigates to a specific diocese detail page. + * @param id - The diocese ID. + */ + async goto(id: string) { + await this.page.goto(`/setting/diocese/${id}`); + } + + /** + * Fills the form with new data. + */ + async fillForm(data: { + name?: string; + address?: string; + phone?: string; + email?: string; + website?: string; + logo?: string; + description?: string; + }) { + if (data.name) await this.nameInput.fill(data.name); + if (data.logo) await this.logoInput.fill(data.logo); + if (data.description) await this.descInput.fill(data.description); + if (data.address) await this.addressInput.fill(data.address); + if (data.phone) await this.phoneInput.fill(data.phone); + if (data.email) await this.emailInput.fill(data.email); + if (data.website) await this.websiteInput.fill(data.website); + } + + /** + * Submits the update. + */ + async submit() { + await this.submitBtn.click(); + } + + /** + * Initiates deletion and confirms it in the dialog. + */ + async deleteAndConfirm() { + await this.deleteBtn.click(); + // Wait for alertDialog to appear and the action button to be visible + await expect(this.confirmDeleteBtn).toBeVisible(); + await this.confirmDeleteBtn.click(); + } +} diff --git a/apps/dash/e2e/pages/setting/DioceseListPage.ts b/apps/dash/e2e/pages/setting/DioceseListPage.ts new file mode 100644 index 0000000..648e64f --- /dev/null +++ b/apps/dash/e2e/pages/setting/DioceseListPage.ts @@ -0,0 +1,95 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Page Object Model for the Diocese List page. + * Encapsulates selectors and actions for browsing and searching dioceses. + */ +export class DioceseListPage { + readonly page: Page; + readonly searchInput: Locator; + readonly searchClearBtn: Locator; + readonly loadingIndicator: Locator; + readonly addDioceseBtn: Locator; + readonly dioceseGrid: Locator; + readonly dioceseCards: Locator; + readonly emptyState: Locator; + readonly premiumHero: Locator; + + constructor(page: Page) { + this.page = page; + this.searchInput = page.getByTestId("search-input"); + this.searchClearBtn = page.getByTestId("search-clear"); + this.loadingIndicator = page.getByTestId("search-loading"); + this.addDioceseBtn = page.getByTestId("add-diocese-btn"); + this.dioceseGrid = page.getByTestId("diocese-grid"); + this.dioceseCards = page.getByTestId("diocese-card"); + this.emptyState = page.getByTestId("diocese-empty-state"); + this.premiumHero = page.getByTestId("premium-hero"); + } + + /** + * Navigates to the diocese list page. + */ + async goto() { + await this.page.goto("/setting/diocese"); + await expect(this.dioceseGrid).toBeVisible(); + } + + /** + * Performs a search by filling the search input and waiting for the URL to update. + * + * @param query - The search query. + */ + async search(query: string) { + await expect(this.searchInput).toBeVisible(); + await this.searchInput.fill(query); + + // If query is not empty, wait for URL to contain 'q=' (due to debounce) + if (query) { + await this.page.waitForURL((url) => url.searchParams.get("q") === query, { + timeout: 10000, + }); + } else { + await this.page.waitForURL((url) => !url.searchParams.has("q"), { + timeout: 10000, + }); + } + + // Wait for internal navigation/loading to finish if applicable + await expect(this.loadingIndicator).toBeHidden(); + } + + /** + * Clears the search input using the clear button. + */ + async clearSearch() { + await this.searchClearBtn.click(); + await expect(this.loadingIndicator).toBeHidden(); + await this.page.waitForURL((url) => !url.searchParams.has("q")); + } + + /** + * Gets the number of currently visible diocese cards. + */ + async getVisibleDioceseCount() { + return await this.dioceseCards.count(); + } + + /** + * Gets a specific diocese card by its index. + * + * @param index - The 0-based index of the card. + */ + getDioceseCardAt(index: number) { + return this.dioceseCards.nth(index); + } + + /** + * Gets a specific diocese card by its name. + * + * @param name - The name of the diocese. + */ + getDioceseCardByName(name: string | RegExp) { + return this.dioceseCards.filter({ hasText: name }); + } +} diff --git a/apps/dash/e2e/pages/setting/ParishCreatePage.ts b/apps/dash/e2e/pages/setting/ParishCreatePage.ts new file mode 100644 index 0000000..5407730 --- /dev/null +++ b/apps/dash/e2e/pages/setting/ParishCreatePage.ts @@ -0,0 +1,84 @@ +import type { Locator, Page } from "@playwright/test"; + +/** + * Page Object Model for the Parish Create page. + * Encapsulates selectors and actions for creating a new parish. + */ +export class ParishCreatePage { + readonly page: Page; + readonly dioceseSelect: Locator; + readonly vicariateSelect: Locator; + readonly nameInput: Locator; + readonly addressInput: Locator; + readonly phoneInput: Locator; + readonly emailInput: Locator; + readonly websiteInput: Locator; + readonly logoInput: Locator; + readonly descInput: Locator; + readonly submitBtn: Locator; + readonly cancelBtn: Locator; + + constructor(page: Page) { + this.page = page; + this.dioceseSelect = page.getByLabel(/diocese|keuskupan/i); + this.vicariateSelect = page.getByLabel(/vicariate|vikariat/i); + this.nameInput = page.getByLabel(/name|nama/i).first(); + this.addressInput = page.getByLabel(/address|alamat/i); + this.phoneInput = page.getByLabel(/phone|telepon/i); + this.emailInput = page.getByLabel(/email/i); + this.websiteInput = page.getByLabel(/website|situs web/i); + this.logoInput = page.getByLabel(/logo/i); + this.descInput = page.getByLabel(/description|deskripsi/i); + this.submitBtn = page.getByRole("button", { name: /save|simpan/i }); + this.cancelBtn = page.getByRole("button", { name: /cancel|batal/i }); + } + + /** + * Navigates to the parish creation page. + */ + async goto() { + await this.page.goto("/setting/parish/new"); + } + + /** + * Fills the parish creation form. + * Handles chained selection for Diocese and Vicariate. + * + * @param data - The parish data. + */ + async fillForm(data: { + dioceseName: string; + vicariateName: string; + name: string; + address?: string; + phone?: string; + email?: string; + website?: string; + logo?: string; + description?: string; + }) { + // 1. Select Diocese + await this.dioceseSelect.click(); + await this.page.getByRole("option", { name: data.dioceseName }).click(); + + // 2. Select Vicariate (Wait for it to load/enable) + await this.vicariateSelect.click(); + await this.page.getByRole("option", { name: data.vicariateName }).click(); + + // 3. Fill text fields + await this.nameInput.fill(data.name); + if (data.logo) await this.logoInput.fill(data.logo); + if (data.description) await this.descInput.fill(data.description); + if (data.address) await this.addressInput.fill(data.address); + if (data.phone) await this.phoneInput.fill(data.phone); + if (data.email) await this.emailInput.fill(data.email); + if (data.website) await this.websiteInput.fill(data.website); + } + + /** + * Submits the form. + */ + async submit() { + await this.submitBtn.click(); + } +} diff --git a/apps/dash/e2e/pages/setting/ParishDetailPage.ts b/apps/dash/e2e/pages/setting/ParishDetailPage.ts new file mode 100644 index 0000000..47d4571 --- /dev/null +++ b/apps/dash/e2e/pages/setting/ParishDetailPage.ts @@ -0,0 +1,108 @@ +import type { Locator, Page } from "@playwright/test"; + +/** + * Page Object Model for the Parish Detail page. + * Encapsulates selectors and actions for viewing and editing a parish. + */ +export class ParishDetailPage { + readonly page: Page; + readonly dioceseSelect: Locator; + readonly vicariateSelect: Locator; + readonly nameInput: Locator; + readonly addressInput: Locator; + readonly phoneInput: Locator; + readonly emailInput: Locator; + readonly websiteInput: Locator; + readonly logoInput: Locator; + readonly descInput: Locator; + readonly submitBtn: Locator; + readonly cancelBtn: Locator; + readonly deleteBtn: Locator; + readonly confirmDeleteBtn: Locator; + + constructor(page: Page) { + this.page = page; + this.dioceseSelect = page.getByLabel(/diocese|keuskupan/i); + this.vicariateSelect = page.getByLabel(/vicariate|vikariat/i); + this.nameInput = page.getByLabel(/name|nama/i).first(); + this.addressInput = page.getByLabel(/address|alamat/i); + this.phoneInput = page.getByLabel(/phone|telepon/i); + this.emailInput = page.getByLabel(/email/i); + this.websiteInput = page.getByLabel(/website|situs web/i); + this.logoInput = page.getByLabel(/logo/i); + this.descInput = page.getByLabel(/description|deskripsi/i); + this.submitBtn = page.getByRole("button", { name: /save|simpan/i }); + this.cancelBtn = page.getByRole("button", { name: /cancel|batal/i }); + this.deleteBtn = page + .getByRole("button", { name: /delete|hapus/i }) + .first(); + this.confirmDeleteBtn = page.locator("role=alertdialog >> role=button", { + hasText: /delete|hapus/i, + }); + } + + /** + * Navigates to the parish detail page by ID. + * @param id - The parish ID. + */ + async goto(id: string) { + await this.page.goto(`/setting/parish/${id}`); + } + + /** + * Fills the parish update form. + * + * @param data - The parish data to update. + */ + async fillForm(data: { + dioceseName?: string; + vicariateName?: string; + name?: string; + address?: string; + phone?: string; + email?: string; + website?: string; + logo?: string; + description?: string; + }) { + // 1. Select Diocese if provided + if (data.dioceseName) { + await this.dioceseSelect.click(); + await this.page.getByRole("option", { name: data.dioceseName }).click(); + } + + // 2. Select Vicariate if provided (Wait for it to load/enable) + if (data.vicariateName) { + await this.vicariateSelect.click(); + await this.page.getByRole("option", { name: data.vicariateName }).click(); + } + + // 3. Fill text fields + if (data.name !== undefined) await this.nameInput.fill(data.name); + if (data.logo !== undefined) await this.logoInput.fill(data.logo); + if (data.description !== undefined) + await this.descInput.fill(data.description); + if (data.address !== undefined) await this.addressInput.fill(data.address); + if (data.phone !== undefined) await this.phoneInput.fill(data.phone); + if (data.email !== undefined) await this.emailInput.fill(data.email); + if (data.website !== undefined) await this.websiteInput.fill(data.website); + } + + /** + * Submits the form. + */ + async submit() { + await this.submitBtn.click(); + } + + /** + * Deletes the parish. + */ + async delete() { + await this.deleteBtn.scrollIntoViewIfNeeded(); + await this.deleteBtn.click(); + // Wait for alert dialog to appear and click confirm + await this.confirmDeleteBtn.waitFor({ state: "visible" }); + await this.confirmDeleteBtn.click(); + } +} diff --git a/apps/dash/e2e/pages/setting/ParishListPage.ts b/apps/dash/e2e/pages/setting/ParishListPage.ts new file mode 100644 index 0000000..275c025 --- /dev/null +++ b/apps/dash/e2e/pages/setting/ParishListPage.ts @@ -0,0 +1,95 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Page Object Model for the Parish List page. + * Encapsulates selectors and actions for browsing and searching parishes. + */ +export class ParishListPage { + readonly page: Page; + readonly searchInput: Locator; + readonly searchClearBtn: Locator; + readonly loadingIndicator: Locator; + readonly addParishBtn: Locator; + readonly parishGrid: Locator; + readonly parishCards: Locator; + readonly emptyState: Locator; + readonly premiumHero: Locator; + + constructor(page: Page) { + this.page = page; + this.searchInput = page.getByTestId("search-input"); + this.searchClearBtn = page.getByTestId("search-clear"); + this.loadingIndicator = page.getByTestId("search-loading"); + this.addParishBtn = page.getByTestId("add-parish-btn"); + this.parishGrid = page.getByTestId("parish-grid"); + this.parishCards = page.getByTestId("parish-card"); + this.emptyState = page.getByTestId("parish-empty-state"); + this.premiumHero = page.getByTestId("premium-hero"); + } + + /** + * Navigates to the parish list page. + */ + async goto() { + await this.page.goto("/setting/parish"); + await expect(this.parishGrid).toBeVisible(); + } + + /** + * Performs a search by filling the search input and waiting for the URL to update. + * + * @param query - The search query. + */ + async search(query: string) { + await expect(this.searchInput).toBeVisible(); + await this.searchInput.fill(query); + + // If query is not empty, wait for URL to contain 'q=' (due to debounce) + if (query) { + await this.page.waitForURL((url) => url.searchParams.get("q") === query, { + timeout: 10000, + }); + } else { + await this.page.waitForURL((url) => !url.searchParams.has("q"), { + timeout: 10000, + }); + } + + // Wait for internal navigation/loading to finish if applicable + await expect(this.loadingIndicator).toBeHidden(); + } + + /** + * Clears the search input using the clear button. + */ + async clearSearch() { + await this.searchClearBtn.click(); + await expect(this.loadingIndicator).toBeHidden(); + await this.page.waitForURL((url) => !url.searchParams.has("q")); + } + + /** + * Gets the number of currently visible parish cards. + */ + async getVisibleParishCount() { + return await this.parishCards.count(); + } + + /** + * Gets a specific parish card by its index. + * + * @param index - The 0-based index of the card. + */ + getParishCardAt(index: number) { + return this.parishCards.nth(index); + } + + /** + * Gets a specific parish card by its name. + * + * @param name - The name of the parish. + */ + getParishCardByName(name: string | RegExp) { + return this.parishCards.filter({ hasText: name }); + } +} diff --git a/apps/dash/playwright.config.ts b/apps/dash/playwright.config.ts index 0e999fd..fa10ac7 100644 --- a/apps/dash/playwright.config.ts +++ b/apps/dash/playwright.config.ts @@ -11,6 +11,8 @@ dotenv.config({ */ export default defineConfig({ testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + globalTeardown: "./e2e/global-teardown.ts", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -40,7 +42,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "pnpm dev", + command: "NEXT_PUBLIC_E2E_MOCK=true pnpm dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, }, diff --git a/apps/dash/proxy.ts b/apps/dash/proxy.ts index db6ec2f..0a3622d 100644 --- a/apps/dash/proxy.ts +++ b/apps/dash/proxy.ts @@ -3,7 +3,7 @@ import c, { Environment } from "@domus/config"; import { cookies } from "next/headers"; import type { NextFetchEvent, NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import auth from "@/shared/auth/server"; +import { getAuthSession } from "@/shared/auth/server"; import { axiomLogger } from "@/shared/axiom/axiom-logger"; import { logger } from "@/shared/core/logger"; import { routing } from "@/shared/i18n/routing"; @@ -34,10 +34,7 @@ export default async function proxy( try { // 1. Get session using the provided request headers - const session: typeof auth.$Infer.Session | null = - await auth.api.getSession({ - headers: request.headers, - }); + const [session, errSession] = await getAuthSession(); // 2. Auth Redirection Logic const isPublicPage = /^\/login/.test(pathname); @@ -46,7 +43,7 @@ export default async function proxy( // 4. Create the response and set local headers const requestHeaders = new Headers(request.headers); - if (!session) { + if (errSession) { // Unauthenticated user if (isPublicPage) { return await finalizeResponse(request, event, NextResponse.next()); diff --git a/apps/dash/src/pages/demo/ui/DemoPage.tsx b/apps/dash/src/pages/demo/ui/DemoPage.tsx index db69160..0dfa183 100644 --- a/apps/dash/src/pages/demo/ui/DemoPage.tsx +++ b/apps/dash/src/pages/demo/ui/DemoPage.tsx @@ -1,88 +1,54 @@ -import { Bell, Calendar, MapPin, Share2 } from "lucide-react"; +import { ArrowRight, Bell, Church, Share2 } from "lucide-react"; import { cn } from "@/shared/ui/common/utils"; +import { PremiumAction, PremiumHero } from "@/shared/ui/components"; import { DomusCard, DomusCardContent, - DomusCardDescription, DomusCardFooter, DomusCardHeader, DomusCardTitle, } from "@/shared/ui/components/DomusCard"; -import { PremiumAction } from "@/shared/ui/components/PremiumFooter"; import { Button } from "@/shared/ui/shadcn/button"; import { RegistrationForm } from "./components/RegistrationForm"; /** - * DemoPage showcases the Premium DomusCard components and their variants. - * It implements a responsive layout (2/3 width on desktop, full width on mobile). + * DemoPage showcases the Premium components available in the Domus design system. */ export default function DemoPage() { - const cathedralImage = - "https://images.unsplash.com/photo-1551759138-3a1d9214d973?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&dl=josh-applegate-g0WjhnQRTa8-unsplash.jpg"; + const heroImage = "https://s3.pkrbt.id/static/cathedral/1.webp"; return ( -
- {/* Page Header */} -
-

- Component Showcase -

-

- Exploring the refined Domus design system. Modern, compact, and - professional components for parish administration. -

-
+
+ {/* Premium Hero Showcase */} + } + orgName="St. Mary's Cathedral" + tags={["Parish", "Barong Tongkok", "Diocese"]} + title={ + <> + Digital Excellence in
+ Parish Administration. + + } + description="A specialized dashboard built for modern ministry. Manage memberships, track sacramental records, and streamline organizational workflows with ease." + actions={ + <> + } + > + GET STARTED + + + VIEW DOCUMENTATION + + + } + /> {/* Main Container*/}
- {/* Variant 1: Professional Cover Card */} -
-

- 01. Premium Cover Variant -

- - - Sunday High Mass - - St. Mary's Cathedral โ€ข 09:00 AM - - - -
-

- Join us for the weekly solemn liturgy. This event features the - full choir and traditional Gregorian chants. Please arrive 15 - minutes early for silent prayer. -

-
-
- - April 13, 2026 -
-
- - Main Altar -
-
-
-
- -
- {[1, 2, 3].map((i) => ( -
- ))} -
- +12 -
-
- RSVP NOW - - -
- {/* Variant 2: Glassmorphism Compact Card */}
diff --git a/apps/dash/src/pages/org/actions/join.ts b/apps/dash/src/pages/org/actions/join.ts index 2193b5f..2253bc7 100644 --- a/apps/dash/src/pages/org/actions/join.ts +++ b/apps/dash/src/pages/org/actions/join.ts @@ -151,6 +151,16 @@ export async function uploadIdCardPhotoAction( const fileName = `id-card-${auth.userId}-${Date.now()}.${ext}`; const folderId = process.env.GDRIVE_FOLDER_ID_CARD; + // E2E Mock bypass + if (process.env.NEXT_PUBLIC_E2E_MOCK === "true") { + logger.info( + "uploadIdCardPhotoAction: E2E MOCK MODE - simulating upload...", + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + const mockFileId = `mock-file-${auth.userId}-${Date.now()}`; + return ok(mockFileId); + } + if (!folderId) { logger.error("uploadIdCardPhotoAction: GDRIVE_FOLDER_ID_CARD is not set"); return fail(new ActionError("Konfigurasi penyimpanan tidak ditemukan")); @@ -198,6 +208,14 @@ export async function deleteIdCardPhotoAction( } try { + // E2E Mock bypass + if (fileId.startsWith("mock-file-")) { + logger.info("deleteIdCardPhotoAction: E2E MOCK MODE - bypassing delete", { + fileId, + }); + return ok(undefined); + } + await privateStorage.delete(fileId); logger.info("deleteIdCardPhotoAction: succeeded", { fileId, diff --git a/apps/dash/src/pages/org/ui/components/JoinForm.tsx b/apps/dash/src/pages/org/ui/components/JoinForm.tsx index 1cc393f..cb2b111 100644 --- a/apps/dash/src/pages/org/ui/components/JoinForm.tsx +++ b/apps/dash/src/pages/org/ui/components/JoinForm.tsx @@ -429,6 +429,7 @@ export function OrganizationJoinForm({ await handlePhotoChange(e, false)} accept="image/*" @@ -644,8 +646,12 @@ export function OrganizationJoinForm({
) : ( -
+
{t("ktpPreviewAlt")}> { } /** - * Server Action: Lists all dioceses. + * Server Action: Lists all dioceses, optionally filtered by name or address. * + * @param q - Optional search query. * @returns Result with the list of dioceses. */ -export async function listDiocesesAction(): Promise> { +export async function listDiocesesAction( + q?: string, +): Promise> { const [auth, authError] = await getAuthContext(); if (authError) return fail(authError); - logger.info("listDiocesesAction: start", { userId: auth.userId }); + logger.info("listDiocesesAction: start", { userId: auth.userId, q }); try { - const [res, error] = await dioceseService.findAll(auth); + const [res, error] = await dioceseService.findAll(auth, q); if (error) { logger.error("listDiocesesAction: failed", { diff --git a/apps/dash/src/pages/setting/actions/parish.ts b/apps/dash/src/pages/setting/actions/parish.ts new file mode 100644 index 0000000..76bc0c1 --- /dev/null +++ b/apps/dash/src/pages/setting/actions/parish.ts @@ -0,0 +1,195 @@ +"use server"; + +import { + CoreError, + type CreateParish, + fail, + ok, + type Parish, + type Result, + type UpdateParish, +} from "@domus/core"; +import { getAuthContext } from "@/shared/auth/server"; +import { logger } from "@/shared/core/logger"; +import { parish as parishService } from "@/shared/core/service"; +import { ActionError } from "@/shared/error/ActionError"; + +/** + * Server Action: Creates a new parish. + * + * @param data - The parish data to create. + * @returns Result with the created parish. + */ +export async function createParishAction( + data: CreateParish, +): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("createParishAction: start", { + userId: auth.userId, + name: data.name, + }); + + try { + const [res, error] = await parishService.create(data, auth); + + if (error) { + logger.error("createParishAction: failed", { + code: error.code, + message: error.message, + }); + return fail(error); + } + + if (!res) { + return fail( + new CoreError("INTERNAL_ERROR", 500, "Failed to create parish"), + ); + } + logger.info("createParishAction: success", { id: res.id }); + return ok(res); + } catch (e) { + logger.error("createParishAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} + +/** + * Server Action: Updates an existing parish. + * + * @param id - The parish id. + * @param data - The parish data to update. + * @returns Result with the updated parish. + */ +export async function updateParishAction( + id: string, + data: UpdateParish, +): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("updateParishAction: start", { userId: auth.userId, id }); + + try { + const [res, error] = await parishService.update(id, data, auth); + + if (error) { + logger.error("updateParishAction: failed", { + id, + code: error.code, + message: error.message, + }); + return fail(error); + } + + if (!res) { + return fail( + new CoreError("INTERNAL_ERROR", 500, "Failed to update parish"), + ); + } + logger.info("updateParishAction: success", { id }); + return ok(res); + } catch (e) { + logger.error("updateParishAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} + +/** + * Server Action: Deletes a parish. + * + * @param id - The parish id. + * @returns Result. + */ +export async function deleteParishAction(id: string): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("deleteParishAction: start", { userId: auth.userId, id }); + + try { + const [_res, error] = await parishService.delete(id, auth); + + if (error) { + logger.error("deleteParishAction: failed", { + id, + code: error.code, + message: error.message, + }); + return fail(error); + } + + logger.info("deleteParishAction: success", { id }); + return ok(undefined); + } catch (e) { + logger.error("deleteParishAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} + +/** + * Server Action: Retrieves a parish by ID. + * + * @param id - The parish id. + * @returns Result with the parish. + */ +export async function getParishAction(id: string): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("getParishAction: start", { userId: auth.userId, id }); + + try { + const [res, error] = await parishService.findById(id, auth); + + if (error) { + logger.error("getParishAction: failed", { + id, + code: error.code, + message: error.message, + }); + return fail(error); + } + + if (!res) { + return fail(new CoreError("NOT_FOUND", 404, "Parish not found")); + } + return ok(res); + } catch (e) { + logger.error("getParishAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} + +/** + * Server Action: Lists all parishes, optionally filtered by a search query. + * + * @param q - Optional search query. + * @returns Result with the list of parishes. + */ +export async function listParishesAction( + q?: string, +): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("listParishesAction: start", { userId: auth.userId, q }); + + try { + const [res, error] = await parishService.findAll(auth, q); + + if (error) { + logger.error("listParishesAction: failed", { + code: error.code, + message: error.message, + }); + return fail(error); + } + + return ok(res ?? []); + } catch (e) { + logger.error("listParishesAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} diff --git a/apps/dash/src/pages/setting/actions/vicariate.ts b/apps/dash/src/pages/setting/actions/vicariate.ts new file mode 100644 index 0000000..6787916 --- /dev/null +++ b/apps/dash/src/pages/setting/actions/vicariate.ts @@ -0,0 +1,45 @@ +"use server"; + +import { fail, ok, type Result, type Vicariate } from "@domus/core"; +import { getAuthContext } from "@/shared/auth/server"; +import { logger } from "@/shared/core/logger"; +import { vicariate as vicariateService } from "@/shared/core/service"; +import { ActionError } from "@/shared/error/ActionError"; + +/** + * Server Action: Lists all vicariates in a specific diocese. + * + * @param dioceseId - The ID of the diocese. + * @returns Result with the list of vicariates. + */ +export async function listVicariatesByDioceseAction( + dioceseId: string, +): Promise> { + const [auth, authError] = await getAuthContext(); + if (authError) return fail(authError); + + logger.info("listVicariatesByDioceseAction: start", { + userId: auth.userId, + dioceseId, + }); + + try { + const [res, error] = await vicariateService.findByDioceseId( + dioceseId, + auth, + ); + + if (error) { + logger.error("listVicariatesByDioceseAction: failed", { + code: error.code, + message: error.message, + }); + return fail(error); + } + + return ok(res ?? []); + } catch (e) { + logger.error("listVicariatesByDioceseAction: error", { error: e }); + return fail(ActionError.from(e)); + } +} diff --git a/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx new file mode 100644 index 0000000..5d8897a --- /dev/null +++ b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx @@ -0,0 +1,70 @@ +import { UserRole } from "@domus/core"; +import { Info } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { getAuthContext } from "@/shared/auth/server"; +import { + DomusCard, + DomusCardDescription, + DomusCardHeader, + DomusCardTitle, +} from "@/shared/ui/components/DomusCard"; +import { createDioceseAction } from "../actions/diocese"; +import { DioceseForm } from "./components/DioceseForm"; + +/** + * Diocese Create Page component. + * Server component that renders the diocese creation flow within a premium DomusCard. + * Uses a cathedral cover image for a spiritual and professional atmosphere. + */ +export async function DioceseCreatePage() { + const t = await getTranslations("DioceseCreatePage"); + const [auth, _authError] = await getAuthContext(); + + // RBAC Gating + const isReadOnly = + !auth || + auth.accountStatus !== "approved" || + !( + auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin) + ); + + if (isReadOnly) { + return ( +
+
+ +
+

Unauthorized access

+
+ ); + } + + return ( +
+ + +
+
+ +
+ + Administration + +
+ + {t("title")} + + + {t("description")} + +
+ +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx b/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx new file mode 100644 index 0000000..b3b4c7a --- /dev/null +++ b/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx @@ -0,0 +1,94 @@ +import { UserRole } from "@domus/core"; +import { Info } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { getAuthContext } from "@/shared/auth/server"; +import { + DomusCard, + DomusCardDescription, + DomusCardHeader, + DomusCardTitle, +} from "@/shared/ui/components/DomusCard"; +import { + deleteDioceseAction, + getDioceseAction, + updateDioceseAction, +} from "../actions/diocese"; +import { DioceseForm } from "./components/DioceseForm"; + +/** + * Props for the DioceseDetailPage. + */ +interface DioceseDetailPageProps { + id: string; +} + +/** + * Diocese Detail Page component. + * Server component that fetches diocese data and renders the edit form. + * Features a solid premium header and RBAC-based field protection. + */ +export async function DioceseDetailPage({ id }: DioceseDetailPageProps) { + const t = await getTranslations("DioceseDetailPage"); + const [diocese, error] = await getDioceseAction(id); + const [auth, _authError] = await getAuthContext(); + + if (error || !diocese) { + return ( +
+
+ +
+

+ {error?.message ?? "Diocese not found"} +

+
+ ); + } + + // RBAC Gating + const isReadOnly = + !auth || + auth.accountStatus !== "approved" || + !( + auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin) + ); + + const updateAction = updateDioceseAction.bind(null, id); + const deleteAction = deleteDioceseAction.bind(null, id); + + return ( +
+ + {/* Solid Premium Header - Emerald 950 for a rich, authoritative ecclesiastical look */} + +
+
+ +
+ + {t("breadcrumb")} + +
+ + {diocese.name} + + + {t("description")} + +
+ + +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/DioceseListPage.tsx b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx new file mode 100644 index 0000000..2534442 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx @@ -0,0 +1,33 @@ +import { UserRole } from "@domus/core"; +import { getAuthContext } from "@/shared/auth/server"; +import { listDiocesesAction } from "../actions/diocese"; +import { DioceseListContent } from "./components/DioceseListContent"; + +/** + * Props for the DioceseListPage. + */ +interface DioceseListPageProps { + searchParams: Promise<{ q?: string }>; +} + +/** + * Server-side entry point for the Diocese List page. + * Fetches the data based on search params and passes the promise to the client. + */ +export default async function DioceseListPage(props: DioceseListPageProps) { + const searchParams = await props.searchParams; + const q = searchParams.q; + + const [auth] = await getAuthContext(); + + const canManage = + !!auth && + auth.accountStatus === "approved" && + (auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin)); + + // Initiate action - returns a promise + const promise = listDiocesesAction(q); + + return ; +} diff --git a/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx new file mode 100644 index 0000000..067c082 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx @@ -0,0 +1,58 @@ +import { UserRole } from "@domus/core"; +import { Info } from "lucide-react"; +import Link from "next/link"; +import { getAuthContext } from "@/shared/auth/server"; +import { listDiocesesAction } from "../actions/diocese"; +import { ParishCreateContent } from "./components/ParishCreateContent"; + +/** + * Server Component: Parish Create Page. + * Fetches required data (Dioceses) and renders the client content. + */ +export default async function ParishCreatePage() { + const [auth, _authError] = await getAuthContext(); + + // RBAC Gating + const isReadOnly = + !auth || + auth.accountStatus !== "approved" || + !( + auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin) + ); + + if (isReadOnly) { + return ( +
+
+ +
+

Unauthorized access

+
+ ); + } + + // Fetch initial dioceses for the dropdown + const [dioceses, _error] = await listDiocesesAction(); + + return ( +
+
+ + Setting + + / + + Parish + + / + New +
+ + +
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx b/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx new file mode 100644 index 0000000..53e2bfe --- /dev/null +++ b/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx @@ -0,0 +1,45 @@ +import { UserRole } from "@domus/core"; +import { notFound } from "next/navigation"; +import { getAuthContext } from "@/shared/auth/server"; +import { listDiocesesAction } from "../actions/diocese"; +import { getParishAction } from "../actions/parish"; +import { ParishDetailContent } from "./components/ParishDetailContent"; + +/** + * Server Component: Parish Detail Page. + * Handles fetching a specific parish and required metadata. + */ +export default async function ParishDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + // Parallel fetch for better performance + const [[parish, error], [dioceses], [auth]] = await Promise.all([ + getParishAction(id), + listDiocesesAction(), + getAuthContext(), + ]); + + if (error || !parish) { + notFound(); + } + + const isReadOnly = + !auth || + auth.accountStatus !== "approved" || + !( + auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin) + ); + + return ( + + ); +} diff --git a/apps/dash/src/pages/setting/ui/ParishListPage.tsx b/apps/dash/src/pages/setting/ui/ParishListPage.tsx new file mode 100644 index 0000000..2972758 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/ParishListPage.tsx @@ -0,0 +1,26 @@ +import { UserRole } from "@domus/core"; +import { getAuthContext } from "@/shared/auth/server"; +import { listParishesAction } from "../actions/parish"; +import { ParishListContent } from "./components/ParishListContent"; + +interface ParishListPageProps { + searchParams: Promise<{ q?: string }>; +} + +/** + * Server-side Page component for the Parish List. + * Fetches data and passes the promise to the client component. + * Supports URL search parameters. + */ +export async function ParishListPage({ searchParams }: ParishListPageProps) { + const { q } = await searchParams; + const promise = listParishesAction(q); + const [auth] = await getAuthContext(); + + const canManage = + auth?.accountStatus === "approved" && + (auth.roles.includes(UserRole.SuperAdmin) || + auth.roles.includes(UserRole.ParishAdmin)); + + return ; +} diff --git a/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx new file mode 100644 index 0000000..2474ee2 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx @@ -0,0 +1,166 @@ +"use client"; + +import type { Diocese } from "@domus/core"; +import { ExternalLink, Globe, Mail, MapPin, Phone } from "lucide-react"; +import { motion } from "motion/react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { cn } from "@/shared/ui/common/utils"; +import { PremiumAction } from "@/shared/ui/components/PremiumAction"; +import { Badge } from "@/shared/ui/shadcn/badge"; +import { buttonVariants } from "@/shared/ui/shadcn/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/shared/ui/shadcn/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/ui/shadcn/tooltip"; + +/** + * Props for the DioceseCard component. + */ +interface DioceseCardProps { + /** The diocese entity data. */ + diocese: Diocese; +} + +/** + * A premium card component for displaying diocese information. + * Features: + * - Glassmorphism effects. + * - Hover elevation and tilt-like micro-animations. + * - Quick action buttons. + * - Responsive layout. + */ +export function DioceseCard({ diocese }: DioceseCardProps) { + const router = useRouter(); + + return ( + + + +
+
+ + Diocese + + + {diocese.name} + +
+ {diocese.logo && ( +
+ {diocese.name} +
+ )} +
+ + {diocese.description ?? + "No description available for this diocese."} + +
+ + +
+ {/* Address */} +
+
+ +
+ + {diocese.address ?? "Address not specified"} + +
+ + {/* Contact Grid */} +
+ + +
+ + {diocese.phone ?? "-"} +
+
+ + {`Phone: ${diocese.phone ?? "N/A"}`} + +
+ + + +
+ + {diocese.email ?? "-"} +
+
+ + {`Email: ${diocese.email ?? "N/A"}`} + +
+
+
+
+ + +
+ {diocese.website ? ( + + + Website + + + ) : ( +
+ )} + + router.push(`/setting/diocese/${diocese.id}`)} + > + Details + +
+ + + + ); +} diff --git a/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx new file mode 100644 index 0000000..6decc4d --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { + type CreateDiocese, + CreateDioceseSchema, + type Diocese, + type Result, +} from "@domus/core"; +import { + AlertTriangle, + Building2, + Check, + Globe, + Image as ImageIcon, + Mail, + Phone, + X, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { logger } from "@/shared/core/logger"; +import { DomusCardContent } from "@/shared/ui/components/DomusCard"; +import { PremiumAction } from "@/shared/ui/components/PremiumAction"; +import ValidationErrors from "@/shared/ui/components/ValidationErrors"; +import { useDomusForm } from "@/shared/ui/fields/context"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/shared/ui/shadcn/alert-dialog"; + +interface DioceseFormProps { + /** The action to perform on submit (create or update). */ + action: (data: CreateDiocese) => Promise>; + /** Initial data for the form (used in update mode). */ + initialData?: Diocese; + /** Optional callback for delete action. Returns Result. */ + onDelete?: () => Promise>; + /** Whether the user has permission to edit. Defaults to true. */ + isReadOnly?: boolean; +} + +/** + * Reusable form component for creating or updating a Diocese. + * Uses useDomusForm for premium field binding and validation. + */ +export function DioceseForm({ + action, + initialData, + onDelete, + isReadOnly = false, +}: DioceseFormProps) { + const t = useTranslations("DioceseForm"); + const tDetail = useTranslations("DioceseDetailPage"); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + const form = useDomusForm({ + defaultValues: { + name: initialData?.name ?? "", + address: initialData?.address ?? "", + phone: initialData?.phone ?? "", + email: initialData?.email ?? undefined, + website: initialData?.website ?? undefined, + logo: initialData?.logo ?? "", + description: initialData?.description ?? "", + } as CreateDiocese, + validators: { + onChange: CreateDioceseSchema, + onBlur: CreateDioceseSchema, + }, + onSubmit: async ({ value }) => { + setIsSubmitting(true); + setErrorMsg(null); + const [data, error] = await action(value as CreateDiocese); + setIsSubmitting(false); + + if (error) { + setErrorMsg(error.message); + toast.error(tDetail("errorToast"), { + description: error.message, + }); + logger.error("[DioceseForm] Submission failed", { error, value }); + return; + } + + if (data) { + toast.success(tDetail("successToast")); + if (!initialData) { + router.push(`/setting/diocese/${data.id}`); + } else { + router.refresh(); + } + } + }, + }); + + const handleDelete = async () => { + if (!onDelete) return; + + setIsDeleting(true); + const [_, error] = await onDelete(); + setIsDeleting(false); + setIsDeleteDialogOpen(false); + + if (error) { + toast.error(tDetail("deleteErrorToast"), { + description: error.message, + }); + return; + } + + toast.success(tDetail("deleteSuccessToast")); + router.replace("/setting/diocese"); + }; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* Section 1: Informasi Dasar */} +
+
+

+ {t("sectionBasic")} +

+
+
+ + {(field) => ( +
+ } + data-testid="diocese-name-input" + required + disabled={isReadOnly} + /> + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+ + {(field) => ( +
+ } + data-testid="diocese-logo-input" + disabled={isReadOnly} + /> + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+
+ + {(field) => ( +
+ + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+
+ + {/* Section 2: Kontak & Kehadiran Digital */} +
+
+

+ {t("sectionContact")} +

+
+ + {(field) => ( +
+ + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+
+ + {(field) => ( +
+ } + data-testid="diocese-phone-input" + disabled={isReadOnly} + /> + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+ + {(field) => ( +
+ } + data-testid="diocese-email-input" + type="email" + disabled={isReadOnly} + /> + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+
+ + {(field) => ( +
+ } + data-testid="diocese-website-input" + disabled={isReadOnly} + /> + {field.state.meta.errors.length > 0 && ( + + )} +
+ )} +
+
+ + {/* Actions */} +
+ {!isReadOnly && ( + + ) : ( + + ) + } + > + {t("btnSave")} + + )} + + router.back()} + disabled={isSubmitting} + icon={} + > + {t("btnCancel")} + + + {!isReadOnly && onDelete && ( +
+ + setIsDeleteDialogOpen(true)} + disabled={isSubmitting || isDeleting} + data-testid="delete-diocese-btn" + icon={} + > + {tDetail("btnDelete")} + + + + + + + + {tDetail("confirmDeleteTitle")} + + + {tDetail("confirmDeleteDesc", { + name: initialData?.name ?? "", + })} + + + + + {t("btnCancel")} + + { + e.preventDefault(); + handleDelete(); + }} + variant="destructive" + > + {isDeleting ? ( +
+ ) : ( + tDetail("btnDelete") + )} + + + + +
+ )} +
+ + + ); +} diff --git a/apps/dash/src/pages/setting/ui/components/DioceseList.tsx b/apps/dash/src/pages/setting/ui/components/DioceseList.tsx new file mode 100644 index 0000000..a3ca440 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/DioceseList.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Diocese, Result } from "@domus/core"; +import { AlertCircle, Search } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { use } from "react"; +import { Card } from "@/shared/ui/shadcn/card"; +import { DioceseCard } from "./DioceseCard"; + +/** + * Props for the DioceseList component. + */ +interface DioceseListProps { + /** A promise that resolves to a result containing an array of dioceses. */ + promise: Promise>; +} + +/** + * Component to render a list of dioceses from a provided promise. + */ +export function DioceseList({ promise }: DioceseListProps) { + const t = useTranslations("DiocesePage"); + const [data, error] = use(promise); + + if (error) { + return ( +
+ +
+
+ +
+
+

+ {t("errorTitle")} +

+

+ {error.message || t("errorMessage")} +

+
+
+
+
+ ); + } + + const dioceses = data ?? []; + + return ( +
+ + {dioceses.length > 0 ? ( + dioceses.map((diocese, index) => ( + + + + )) + ) : ( + +
+ +
+

{t("emptyState")}

+

{t("emptyStateDesc")}

+
+ )} +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx new file mode 100644 index 0000000..bdacdb5 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { Diocese, Result } from "@domus/core"; +import { Building2, Plus } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Suspense, useEffect, useState, useTransition } from "react"; +import { + PremiumAction, + PremiumHero, + PremiumSearch, +} from "@/shared/ui/components"; +import { DioceseList } from "./DioceseList"; + +/** + * Props for the DioceseListContent component. + */ +interface DioceseListContentProps { + /** Initial promise from server. */ + promise: Promise>; + /** Whether the user has permission to add/manage dioceses. */ + canManage?: boolean; +} + +/** + * Client-side layout and state management for the Diocese list page. + * Handles URL-based search filtering and data presentation. + */ +export function DioceseListContent({ + promise, + canManage = false, +}: DioceseListContentProps) { + const t = useTranslations("DiocesePage"); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const [search, setSearch] = useState(searchParams?.get("q") ?? ""); + + // Debounced URL sync + useEffect(() => { + const timer = setTimeout(() => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + const currentQ = params.get("q") ?? ""; + + // Skip if identity + if (search === currentQ) return; + + if (search) { + params.set("q", search); + } else { + params.delete("q"); + } + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`); + }); + }, 400); + + return () => clearTimeout(timer); + }, [search, pathname, router, searchParams]); + + return ( +
+ {/* Premium Hero Section */} + } + orgName="Administration" + tags={["Diocese", "Hierarchy", "Territory"]} + title={t("title")} + description={t("description")} + actions={ + canManage && ( + router.push("/setting/diocese/new")} + variant="primary" + icon={} + > + {t("addDiocese")} + + ) + } + /> + + {/* Filter Bar */} +
+ +
+
+ + {/* List Section */} + + {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ } + > + +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishCard.tsx b/apps/dash/src/pages/setting/ui/components/ParishCard.tsx new file mode 100644 index 0000000..b5ab7ae --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishCard.tsx @@ -0,0 +1,68 @@ +"use client"; + +import type { Parish } from "@domus/core"; +import { Building2, Globe, Mail, Phone } from "lucide-react"; +import Link from "next/link"; +import { getRandomCathedralImage } from "@/shared/config/images"; +import { + DomusCard, + DomusCardContent, + DomusCardDescription, + DomusCardHeader, + DomusCardTitle, +} from "@/shared/ui/components/DomusCard"; + +interface ParishCardProps { + parish: Parish; +} + +/** + * Premium card component to display parish information. + */ +export function ParishCard({ parish }: ParishCardProps) { + return ( + + + + {parish.name} + {parish.address} + + +
+ {parish.phone && ( +
+ + {parish.phone} +
+ )} + {parish.email && ( +
+ + {parish.email} +
+ )} + {parish.website && ( +
+ + {parish.website} +
+ )} +
+ + + {parish.vicariateId ? "Vicariate Scoped" : "General Parish"} + +
+
+
+
+ + ); +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishCreateContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishCreateContent.tsx new file mode 100644 index 0000000..e2d15ee --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishCreateContent.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { Diocese } from "@domus/core"; +import { useTranslations } from "next-intl"; +import { createParishAction } from "@/pages/setting/actions/parish"; +import { + DomusCard, + DomusCardDescription, + DomusCardHeader, + DomusCardTitle, +} from "@/shared/ui/components/DomusCard"; +import { ParishForm } from "./ParishForm"; + +interface ParishCreateContentProps { + /** Initial list of dioceses for the selection. */ + dioceses: Diocese[]; +} + +/** + * Client container for the Parish Create Page. + * Renders the form within a premium glassmorphic card. + */ +export function ParishCreateContent({ dioceses }: ParishCreateContentProps) { + const t = useTranslations("ParishCreatePage"); + + return ( +
+ + + + {t("title")} + + + {t("description")} + + + + +
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx new file mode 100644 index 0000000..c12066f --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { Diocese, Parish } from "@domus/core"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { + DomusCard, + DomusCardDescription, + DomusCardHeader, + DomusCardTitle, +} from "@/shared/ui/components/DomusCard"; +import { deleteParishAction, updateParishAction } from "../../actions/parish"; +import { ParishForm } from "./ParishForm"; + +interface ParishDetailContentProps { + parish: Parish & { dioceseId?: string }; + dioceses: Diocese[]; + isReadOnly?: boolean; +} + +/** + * Client Component: Parish Detail Page Content. + * Features a premium glassmorphic UI container for the edit form. + */ +export function ParishDetailContent({ + parish, + dioceses, + isReadOnly = false, +}: ParishDetailContentProps) { + const t = useTranslations("ParishDetailPage"); + + const handleDelete = async () => { + return await deleteParishAction(parish.id); + }; + + return ( +
+ {/* Visual Breadcrumb */} +
+ + Setting + + / + + Parish + + / + + {parish.name} + +
+ +
+ + + + {t("title")} + + + {t("description")} + + + + updateParishAction(parish.id, data)} + dioceses={dioceses} + initialData={parish} + onDelete={handleDelete} + isReadOnly={isReadOnly} + /> + +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishForm.tsx b/apps/dash/src/pages/setting/ui/components/ParishForm.tsx new file mode 100644 index 0000000..a50d639 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishForm.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { + type CreateParish, + CreateParishSchema, + type Diocese, + type Parish, + type Result, + type Vicariate, +} from "@domus/core"; +import { + AlertTriangle, + Building2, + Check, + Globe, + Image as ImageIcon, + Mail, + Phone, + X, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { listVicariatesByDioceseAction } from "@/pages/setting/actions/vicariate"; +import { logger } from "@/shared/core/logger"; +import { DomusCardContent } from "@/shared/ui/components/DomusCard"; +import { PremiumAction } from "@/shared/ui/components/PremiumAction"; +import { useDomusForm } from "@/shared/ui/fields/context"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/shared/ui/shadcn/alert-dialog"; + +const FormSchema = CreateParishSchema.extend({ + dioceseId: z.string().uuid().or(z.literal("")), +}); + +type FormValue = z.infer; + +interface ParishFormProps { + /** The action to perform on submit (create or update). */ + action: (data: CreateParish) => Promise>; + /** List of all dioceses for selection. */ + dioceses: Diocese[]; + /** Initial data for the form (used in update mode). */ + initialData?: Parish & { dioceseId?: string }; + /** Optional callback for delete action. Returns Result. */ + onDelete?: () => Promise>; + /** Whether the user has permission to edit. Defaults to false. */ + isReadOnly?: boolean; +} + +/** + * Reusable form component for creating or updating a Parish. + * Handles hierarchy dependencies (Diocese -> Vicariate) and validation. + */ +export function ParishForm({ + action, + dioceses, + initialData, + onDelete, + isReadOnly = false, +}: ParishFormProps) { + const t = useTranslations("ParishForm"); + const tDetail = useTranslations("ParishDetailPage"); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + const [vicariates, setVicariates] = useState([]); + const [isPendingVicariates, startTransition] = useTransition(); + + const form = useDomusForm({ + defaultValues: { + dioceseId: (initialData?.dioceseId ?? "") as string, + vicariateId: (initialData?.vicariateId ?? "") as string, + name: initialData?.name ?? "", + address: initialData?.address ?? "", + phone: initialData?.phone ?? "", + email: initialData?.email ?? undefined, + website: initialData?.website ?? undefined, + logo: initialData?.logo ?? "", + description: initialData?.description ?? "", + } as FormValue, + validators: { + onChange: FormSchema, + onBlur: FormSchema, + }, + onSubmit: async ({ value }) => { + setIsSubmitting(true); + setErrorMsg(null); + + // Remove dioceseId before sending to action + const { dioceseId: _, ...submitData } = value; + + const [data, error] = await action(submitData as CreateParish); + setIsSubmitting(false); + + if (error) { + setErrorMsg(error.message); + toast.error(tDetail("errorToast"), { + description: error.message, + }); + logger.error("[ParishForm] Submission failed", { error, value }); + return; + } + + if (data) { + toast.success(tDetail("successToast")); + if (!initialData) { + router.push("/setting/parish"); + } else { + router.refresh(); + } + } + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* Section 1: Hierarchy Selection */} +
+
+

+ {t("sectionBasic")} +

+
+
+ + {(field) => ( +
+ ({ + label: d.name, + value: d.id, + }))} + disabled={isReadOnly} + /> +
+ )} +
+ + state.values.dioceseId}> + {(selectedDioceseId) => ( + + {(field) => ( +
+ + ({ + label: v.name, + value: v.id, + }))} + disabled={ + isReadOnly || + !selectedDioceseId || + isPendingVicariates + } + /> +
+ )} +
+ )} +
+
+
+ + {/* Parish Identity */} +
+
+ + {(field) => ( +
+ } + required + disabled={isReadOnly} + /> +
+ )} +
+ + {(field) => ( + } + disabled={isReadOnly} + /> + )} + +
+ + {(field) => ( + + )} + +
+ + {/* Contact & Digital */} +
+
+

+ {t("sectionContact")} +

+
+ + {(field) => ( + + )} + +
+ + {(field) => ( + } + disabled={isReadOnly} + /> + )} + + + {(field) => ( + } + type="email" + disabled={isReadOnly} + /> + )} + +
+ + {(field) => ( + } + disabled={isReadOnly} + /> + )} + +
+ + {/* Actions */} +
+ {!isReadOnly && ( + + ) : ( + + ) + } + > + {t("btnSave")} + + )} + + router.back()} + disabled={isSubmitting} + icon={} + > + {t("btnCancel")} + + + {!isReadOnly && onDelete && ( +
+ + setIsDeleteDialogOpen(true)} + disabled={isSubmitting || isDeleting} + icon={} + > + {tDetail("btnDelete")} + + + + + + + + {tDetail("confirmDeleteTitle")} + + + {tDetail("confirmDeleteDesc", { + name: initialData?.name ?? "", + })} + + + + + {t("btnCancel")} + + { + e.preventDefault(); + handleDelete(); + }} + variant="destructive" + > + {isDeleting ? ( +
+ ) : ( + tDetail("btnDelete") + )} + + + + +
+ )} +
+ + + ); + + async function handleDelete() { + if (!onDelete) return; + setIsDeleting(true); + const [_, error] = await onDelete(); + setIsDeleting(false); + setIsDeleteDialogOpen(false); + + if (error) { + toast.error(tDetail("deleteErrorToast"), { + description: error.message, + }); + return; + } + + toast.success(tDetail("deleteSuccessToast")); + router.replace("/setting/parish"); + } +} + +/** + * Invisible component to handle vicariate loading effect inside form.Subscribe. + */ +function ParishFormVicariateLoader({ + selectedDioceseId, + setVicariates, + setIsPendingVicariates, +}: { + selectedDioceseId: string; + setVicariates: (data: Vicariate[]) => void; + setIsPendingVicariates: (fn: () => void) => void; +}) { + useEffect(() => { + if (!selectedDioceseId) { + setVicariates([]); + return; + } + + setIsPendingVicariates(async () => { + const [data, error] = + await listVicariatesByDioceseAction(selectedDioceseId); + if (error) { + toast.error("Failed to load vicariates"); + return; + } + setVicariates(data ?? []); + }); + }, [selectedDioceseId, setVicariates, setIsPendingVicariates]); + + return null; +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishList.tsx b/apps/dash/src/pages/setting/ui/components/ParishList.tsx new file mode 100644 index 0000000..a739650 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishList.tsx @@ -0,0 +1,87 @@ +"use client"; + +import type { Parish, Result } from "@domus/core"; +import { AlertCircle, Search } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { use } from "react"; +import { Card } from "@/shared/ui/shadcn/card"; +import { ParishCard } from "./ParishCard"; + +interface ParishListProps { + /** A promise that resolves to a result containing an array of parishes. */ + promise: Promise>; +} + +/** + * Component to render a list of parishes from a provided promise. + * Uses the `use()` hook for data fetching within Suspense. + */ +export function ParishList({ promise }: ParishListProps) { + const t = useTranslations("ParishPage"); + const [data, error] = use(promise); + + if (error) { + return ( +
+ +
+
+ +
+
+

+ {t("errorTitle")} +

+

+ {error.message || t("errorMessage")} +

+
+
+
+
+ ); + } + + const parishes = data ?? []; + + return ( +
+ + {parishes.length > 0 ? ( + parishes.map((parish, index) => ( + + + + )) + ) : ( + +
+ +
+

{t("emptyState")}

+

{t("emptyStateDesc")}

+
+ )} +
+
+ ); +} diff --git a/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx new file mode 100644 index 0000000..046ab37 --- /dev/null +++ b/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx @@ -0,0 +1,107 @@ +"use client"; + +import type { Parish, Result } from "@domus/core"; +import { Building2, Plus } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Suspense, useEffect, useState, useTransition } from "react"; +import { PremiumAction } from "@/shared/ui/components/PremiumAction"; +import { PremiumHero } from "@/shared/ui/components/PremiumHero"; +import { PremiumSearch } from "@/shared/ui/components/PremiumSearch"; +import { ParishList } from "./ParishList"; + +interface ParishListContentProps { + /** Initial promise from server. */ + promise: Promise>; + /** Whether the user has permission to manage parishes. */ + canManage?: boolean; +} + +/** + * Client-side layout and state management for the Parish list page. + * Syncs search state with URL and handles presentation. + */ +export function ParishListContent({ + promise, + canManage = false, +}: ParishListContentProps) { + const t = useTranslations("ParishPage"); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const [search, setSearch] = useState(searchParams?.get("q") ?? ""); + + // Debounced URL sync + useEffect(() => { + const timer = setTimeout(() => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + const currentQ = params.get("q") ?? ""; + + if (search === currentQ) return; + + if (search) { + params.set("q", search); + } else { + params.delete("q"); + } + + startTransition(() => { + router.push(`${pathname}?${params.toString()}`); + }); + }, 400); + + return () => clearTimeout(timer); + }, [search, pathname, router, searchParams]); + + return ( +
+ } + orgName="Administration" + tags={["Parish", "Organization", "Territory"]} + title={t("title")} + description={t("description")} + actions={ + canManage && ( + router.push("/setting/parish/new")} + variant="primary" + icon={} + data-testid="add-parish-btn" + > + {t("addParish")} + + ) + } + /> + +
+ +
+
+ + + {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ } + > + +
+
+ ); +} diff --git a/apps/dash/src/shared/config/actions.ts b/apps/dash/src/shared/config/actions.ts index 50c70b1..b1aaada 100644 --- a/apps/dash/src/shared/config/actions.ts +++ b/apps/dash/src/shared/config/actions.ts @@ -1,4 +1,5 @@ import { + Building2, CalendarDays, CheckCircle2, Church, @@ -83,6 +84,13 @@ export const DASHBOARD_ACTIONS: LayananItem[] = [ icon: Church, color: "bg-[#0288d1] text-white", hoverTextColor: "hover:text-[#0288d1]", - href: "/diocese", + href: "/setting/diocese", + }, + { + id: "parishes", + icon: Building2, + color: "bg-[#455a64] text-white", + hoverTextColor: "hover:text-[#455a64]", + href: "/setting/parish", }, ]; diff --git a/apps/dash/src/shared/config/images.ts b/apps/dash/src/shared/config/images.ts new file mode 100644 index 0000000..f317805 --- /dev/null +++ b/apps/dash/src/shared/config/images.ts @@ -0,0 +1,28 @@ +/** + * Static asset URLs for cathedral images used as fallbacks for Parish/Diocese cards. + */ +export const RANDOM_CATHEDRAL_IMAGES = [ + "https://s3.pkrbt.id/static/cathedral/1.webp", + "https://s3.pkrbt.id/static/cathedral/2.webp", + "https://s3.pkrbt.id/static/cathedral/3.webp", + "https://s3.pkrbt.id/static/cathedral/4.webp", + "https://s3.pkrbt.id/static/cathedral/5.webp", + "https://s3.pkrbt.id/static/cathedral/6.webp", +] as const; + +/** + * Returns a deterministic "random" cathedral image based on a string ID. + * This ensures consistency across renders and prevents hydration mismatches. + * + * @param id - The unique identifier (e.g., Parish ID or Diocese ID). + * @returns A cathedral image URL. + */ +export function getRandomCathedralImage(id: string): string { + // Simple deterministic hash based on ID + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % RANDOM_CATHEDRAL_IMAGES.length; + return RANDOM_CATHEDRAL_IMAGES[index]; +} diff --git a/apps/dash/src/shared/core/index.ts b/apps/dash/src/shared/core/index.ts index 9f19f0a..3444ece 100644 --- a/apps/dash/src/shared/core/index.ts +++ b/apps/dash/src/shared/core/index.ts @@ -16,6 +16,8 @@ export const service = { transaction: s.transaction, user: s.user, diocese: s.diocese, + parish: s.parish, + vicariate: s.vicariate, }; export default service; diff --git a/apps/dash/src/shared/core/service.ts b/apps/dash/src/shared/core/service.ts index 259e33d..f140d4f 100644 --- a/apps/dash/src/shared/core/service.ts +++ b/apps/dash/src/shared/core/service.ts @@ -10,12 +10,14 @@ import { NotificationService, OrganizationService, ParishionerService, + ParishService, PlacementService, RsvpService, TermService, TransactionService, UnitService, UserService, + VicariateService, } from "@domus/core"; import { createRepositories, db } from "@domus/db"; import { emitter } from "./emitter"; @@ -119,3 +121,5 @@ export const user = new UserService( logger, ); export const diocese = new DioceseService(repo.diocese, logger); +export const vicariate = new VicariateService(repo.vicariate, logger); +export const parish = new ParishService(repo.parish, logger); diff --git a/apps/dash/src/shared/i18n/messages/en.json b/apps/dash/src/shared/i18n/messages/en.json index d26922d..a38494d 100644 --- a/apps/dash/src/shared/i18n/messages/en.json +++ b/apps/dash/src/shared/i18n/messages/en.json @@ -69,17 +69,94 @@ "DiocesePage": { "title": "Diocese List", "description": "Manage diocese data in the Domus system.", - "addDioceseTooltip": "Add Diocese", + "addDiocese": "Add Diocese", + "searchPlaceholder": "Search dioceses...", "emptyState": "No dioceses found.", - "errorTitle": "An Error Occurred", + "emptyStateDesc": "Try adjusting your search terms or filters.", + "errorTitle": "Something went wrong", "errorMessage": "Failed to load the diocese list." }, + "DioceseCreatePage": { + "title": "Add New Diocese", + "description": "Complete the information details to register a new diocese structure.", + "successToast": "Diocese added successfully.", + "errorToast": "Failed to add diocese." + }, + "Common": { + "searchPlaceholder": "Search..." + }, "DioceseForm": { + "sectionBasic": "Basic Information", + "sectionContact": "Contact & Digital Presence", "nameLabel": "Diocese Name", "namePlaceholder": "Enter diocese name...", + "addressLabel": "Address", + "addressPlaceholder": "Enter full address...", + "phoneLabel": "Phone", + "phonePlaceholder": "Example: +62...", + "emailLabel": "Email", + "emailPlaceholder": "diocese@example.id", + "websiteLabel": "Website", + "websitePlaceholder": "https://...", + "logoLabel": "Logo URL", + "logoPlaceholder": "https://... (PNG/JPG)", + "descLabel": "Description", + "descPlaceholder": "Short profile of the diocese...", + "btnSave": "Save", + "btnCancel": "Cancel" + }, + "ParishPage": { + "title": "Parish List", + "description": "Manage parish data in the Domus system.", + "addParish": "Add Parish", + "searchPlaceholder": "Search parishes...", + "emptyState": "No parishes found.", + "emptyStateDesc": "Try adjusting your search terms or filters.", + "errorTitle": "Something went wrong", + "errorMessage": "Failed to load the parish list." + }, + "ParishCreatePage": { + "title": "Add New Parish", + "description": "Complete the information details to register a new parish.", + "successToast": "Parish added successfully.", + "errorToast": "Failed to add parish." + }, + "ParishForm": { + "sectionBasic": "Basic Information", + "sectionContact": "Contact & Digital Presence", + "nameLabel": "Parish Name", + "namePlaceholder": "Enter parish name...", + "addressLabel": "Address", + "addressPlaceholder": "Enter full address...", + "phoneLabel": "Phone", + "phonePlaceholder": "Example: +62...", + "emailLabel": "Email", + "emailPlaceholder": "parish@example.id", + "websiteLabel": "Website", + "websitePlaceholder": "https://...", + "logoLabel": "Logo URL", + "logoPlaceholder": "https://... (PNG/JPG)", + "descLabel": "Description", + "descPlaceholder": "Short profile of the parish...", + "dioceseLabel": "Diocese", + "diocesePlaceholder": "Select Diocese", + "vicariateLabel": "Vicariate", + "vicariatePlaceholder": "Select Vicariate", "btnSave": "Save", "btnCancel": "Cancel" }, + "ParishDetailPage": { + "breadcrumb": "Parish", + "title": "Edit Parish", + "description": "Update detailed parish information in the Domus system.", + "successToast": "Parish updated successfully.", + "errorToast": "Failed to update parish.", + "deleteSuccessToast": "Parish deleted successfully.", + "deleteErrorToast": "Failed to delete parish.", + "confirmDeleteTitle": "Delete Parish?", + "confirmDeleteDesc": "Are you sure you want to delete {name}? This action will hide the data from active lists (Soft Delete).", + "btnDelete": "Delete" + }, "OrgPage": { "title": "Organization List", "description": "Discover and manage your spiritual communities at Domus.", @@ -399,6 +476,11 @@ "title": "Dioceses", "description": "Diocese data management.", "actionLabel": "Open Dioceses" + }, + "parishes": { + "title": "Parishes", + "description": "Parish data management.", + "actionLabel": "Open Parishes" } }, "PendingEnrollmentPage": { diff --git a/apps/dash/src/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json index 167ad80..90c723a 100644 --- a/apps/dash/src/shared/i18n/messages/id.json +++ b/apps/dash/src/shared/i18n/messages/id.json @@ -69,17 +69,106 @@ "DiocesePage": { "title": "Daftar Keuskupan", "description": "Kelola data keuskupan dalam sistem Domus.", - "addDioceseTooltip": "Tambah Keuskupan", - "emptyState": "Tidak ada keuskupan yang ditemukan.", + "addDiocese": "Tambah Keuskupan", + "searchPlaceholder": "Cari keuskupan...", + "emptyState": "Tidak ada keuskupan ditemukan.", + "emptyStateDesc": "Coba sesuaikan kata kunci atau filter Anda.", "errorTitle": "Terjadi Kesalahan", "errorMessage": "Gagal memuat daftar keuskupan." }, + "DioceseCreatePage": { + "title": "Tambah Keuskupan Baru", + "description": "Lengkapi detail informasi untuk mendaftarkan struktur keuskupan baru.", + "successToast": "Keuskupan berhasil ditambahkan.", + "errorToast": "Gagal menambahkan keuskupan." + }, + "Common": { + "searchPlaceholder": "Cari..." + }, "DioceseForm": { + "sectionBasic": "Informasi Dasar", + "sectionContact": "Kontak & Kehadiran Digital", "nameLabel": "Nama Keuskupan", "namePlaceholder": "Masukkan nama keuskupan...", + "addressLabel": "Alamat", + "addressPlaceholder": "Masukkan alamat lengkap...", + "phoneLabel": "Telepon", + "phonePlaceholder": "Contoh: +62...", + "emailLabel": "Email", + "emailPlaceholder": "keuskupan@contoh.id", + "websiteLabel": "Website", + "websitePlaceholder": "https://...", + "logoLabel": "URL Logo", + "logoPlaceholder": "https://... (PNG/JPG)", + "descLabel": "Deskripsi", + "descPlaceholder": "Profil singkat keuskupan...", "btnSave": "Simpan", "btnCancel": "Batal" }, + "DioceseDetailPage": { + "breadcrumb": "Keuskupan", + "title": "Edit Keuskupan", + "description": "Perbarui informasi detail keuskupan dalam sistem Domus.", + "successToast": "Keuskupan berhasil diperbarui.", + "errorToast": "Gagal memperbarui keuskupan.", + "deleteSuccessToast": "Keuskupan berhasil dihapus.", + "deleteErrorToast": "Gagal menghapus keuskupan.", + "confirmDeleteTitle": "Hapus Keuskupan?", + "confirmDeleteDesc": "Apakah Anda yakin ingin menghapus {name}? Tindakan ini akan menyembunyikan data dari daftar aktif (Soft Delete).", + "btnDelete": "Hapus" + }, + "ParishPage": { + "title": "Daftar Paroki", + "description": "Kelola data paroki dalam sistem Domus.", + "addParish": "Tambah Paroki", + "searchPlaceholder": "Cari paroki...", + "emptyState": "Tidak ada paroki ditemukan.", + "emptyStateDesc": "Coba sesuaikan kata kunci atau filter Anda.", + "errorTitle": "Terjadi Kesalahan", + "errorMessage": "Gagal memuat daftar paroki." + }, + "ParishCreatePage": { + "title": "Tambah Paroki Baru", + "description": "Lengkapi detail informasi untuk mendaftarkan paroki baru.", + "successToast": "Paroki berhasil ditambahkan.", + "errorToast": "Gagal menambahkan paroki." + }, + "ParishForm": { + "sectionBasic": "Informasi Dasar", + "sectionContact": "Kontak & Kehadiran Digital", + "nameLabel": "Nama Paroki", + "namePlaceholder": "Masukkan nama paroki...", + "addressLabel": "Alamat", + "addressPlaceholder": "Masukkan alamat lengkap...", + "phoneLabel": "Telepon", + "phonePlaceholder": "Contoh: +62...", + "emailLabel": "Email", + "emailPlaceholder": "paroki@contoh.id", + "websiteLabel": "Website", + "websitePlaceholder": "https://...", + "logoLabel": "URL Logo", + "logoPlaceholder": "https://... (PNG/JPG)", + "descLabel": "Deskripsi", + "descPlaceholder": "Profil singkat paroki...", + "dioceseLabel": "Keuskupan", + "diocesePlaceholder": "Pilih Keuskupan", + "vicariateLabel": "Vikariat", + "vicariatePlaceholder": "Pilih Vikariat", + "btnSave": "Simpan", + "btnCancel": "Batal" + }, + "ParishDetailPage": { + "breadcrumb": "Paroki", + "title": "Edit Paroki", + "description": "Perbarui informasi detail paroki dalam sistem Domus.", + "successToast": "Paroki berhasil diperbarui.", + "errorToast": "Gagal memperbarui paroki.", + "deleteSuccessToast": "Paroki berhasil dihapus.", + "deleteErrorToast": "Gagal menghapus paroki.", + "confirmDeleteTitle": "Hapus Paroki?", + "confirmDeleteDesc": "Apakah Anda yakin ingin menghapus {name}? Tindakan ini akan menyembunyikan data dari daftar aktif (Soft Delete).", + "btnDelete": "Hapus" + }, "OrgPage": { "title": "Daftar Organisasi", "description": "Temukan dan kelola komunitas rohani Anda di Domus.", @@ -408,6 +497,11 @@ "title": "Keuskupan", "description": "Manajemen data Keuskupan.", "actionLabel": "Buka Keuskupan" + }, + "parishes": { + "title": "Paroki", + "description": "Manajemen data Paroki.", + "actionLabel": "Buka Paroki" } }, "PendingEnrollmentPage": { diff --git a/apps/dash/src/shared/ui/components/DomusCard.tsx b/apps/dash/src/shared/ui/components/DomusCard.tsx index 33fe32e..e41d5dd 100644 --- a/apps/dash/src/shared/ui/components/DomusCard.tsx +++ b/apps/dash/src/shared/ui/components/DomusCard.tsx @@ -10,7 +10,7 @@ import { CardDescription, CardFooter, CardHeader, - CardTitle, + type CardTitle, } from "@/shared/ui/shadcn/card"; /** @@ -167,12 +167,14 @@ export function DomusCardHeader({ */ export function DomusCardTitle({ className, + as: Component = "h2", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { as?: React.ElementType }) { const { isOverImage } = React.useContext(DomusCardHeaderContext); return ( - + {/* Background Image with subtle parallax-ready scale */} +
+ + {/* Cinematic Overlays - Layered for maximum contrast */} +
+
+
+
+ + {/* Content Layer */} +
+ {/* Glassmorphic Org Card */} + {(logo || orgName || tags) && ( + +
+ {logo && ( +
+ {logo} +
+ )} + {orgName && ( +
+ + Organization + + + {orgName} + +
+ )} +
+ {tags && tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ )} + + {/* Hero Copy */} +
+ + {title} + + {description && ( + + {description} + + )} +
+ + {/* Actions */} + {actions && ( + + {actions} + + )} +
+ + {/* Aesthetic Accents */} +
+
+
+ + ); +} diff --git a/apps/dash/src/shared/ui/components/PremiumSearch.tsx b/apps/dash/src/shared/ui/components/PremiumSearch.tsx new file mode 100644 index 0000000..8a6730a --- /dev/null +++ b/apps/dash/src/shared/ui/components/PremiumSearch.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Loader2, Search, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useTransition } from "react"; +import { cn } from "@/shared/ui/common/utils"; +import { Input } from "@/shared/ui/shadcn/input"; + +/** + * Props for the PremiumSearch component. + */ +interface PremiumSearchProps { + /** The current search value. */ + value: string; + /** Callback when the value changes. */ + onChange: (value: string) => void; + /** Optional placeholder text. */ + placeholder?: string; + /** Optional loading state. */ + isLoading?: boolean; + /** Optional container class name. */ + className?: string; +} + +/** + * A reusable, premium search component with glassmorphism aesthetics. + * Features: + * - Glassmorphism background and borders. + * - Smooth micro-animations for focus and loading states. + * - Integrated clear button. + * - Responsive design. + */ +export function PremiumSearch({ + value, + onChange, + placeholder, + isLoading = false, + className, +}: PremiumSearchProps) { + const t = useTranslations("Common"); + const [isPending, startTransition] = useTransition(); + + const handleClear = () => { + startTransition(() => { + onChange(""); + }); + }; + + return ( + + {/* Search Icon */} +
+ {isLoading || isPending ? ( + + ) : ( + + )} +
+ + {/* Input Field */} + onChange(e.target.value)} + placeholder={placeholder ?? t("searchPlaceholder")} + className={cn( + "pl-11 pr-11 h-11 w-full bg-white/40 dark:bg-white/5 backdrop-blur-xl", + "border-white/20 dark:border-white/10 shadow-xl shadow-black/5", + "rounded-2xl transition-all duration-500", + "focus-visible:ring-primary/20 focus-visible:border-primary/50", + "placeholder:text-muted-foreground/50 text-sm font-medium", + )} + /> + + {/* Clear Button */} + + {value && ( + + + + )} + + + {/* Premium Glow Effect */} +
+ + ); +} diff --git a/apps/dash/src/shared/ui/components/ValidationErrors.tsx b/apps/dash/src/shared/ui/components/ValidationErrors.tsx index d68cd3e..ab7bad9 100644 --- a/apps/dash/src/shared/ui/components/ValidationErrors.tsx +++ b/apps/dash/src/shared/ui/components/ValidationErrors.tsx @@ -40,11 +40,17 @@ export default function ValidationErrors({ if (activeErrors.length === 0) return null; + // Deduplicate errors to avoid key collisions and redundant messages + const uniqueErrors = Array.from( + new Set( + activeErrors.map((err) => (typeof err === "string" ? err : err.message)), + ), + ); + return (
- {activeErrors.map((err, index) => { - const message = typeof err === "string" ? err : err.message; + {uniqueErrors.map((message) => { return ( (); const activeErrors = field.state.meta.errors; @@ -87,6 +90,7 @@ export function SelectField({ **Note:** `songs` is stored as raw text to preserve the original Puji Syukur numbering format from lagumisa.web.id, including range notations (e.g., `"464 (1, 2, 4)"`). Parsing into structured data is deferred to a future iteration. + +--- + +### 2.6 Events & Attendance #### `events` @@ -430,6 +484,7 @@ Represents kelurahan/desa level. |---|---|---| | `id` | uuid v7 | Primary key | | `organizationId` | uuid v7? | FK โ†’ `organizations.id`. Nullable for public events | +| `ordoId` | uuid v7? | FK โ†’ `ordo.id`. Optional link to the liturgical celebration this event corresponds to. | | `name` | string | Required | | `description` | string? | | | `location` | string | Required | @@ -484,7 +539,7 @@ Represents kelurahan/desa level. --- -### 2.6 Finance +### 2.7 Finance #### `financial_periods` @@ -533,7 +588,7 @@ Represents kelurahan/desa level. --- -### 2.7 Attachments +### 2.8 Attachments #### `attachments` @@ -553,7 +608,7 @@ Represents kelurahan/desa level. --- -### 2.8 Notifications +### 2.9 Notifications #### `notifications` @@ -595,6 +650,7 @@ erDiagram users ||--o{ attendances : "verifies" users ||--o{ events : "creates" users ||--o{ attachments : "uploads" + users ||--o{ ordo : "creates" parishioners ||--o{ org_enrollments : "enrolled in" parishioners ||--o{ rsvp : "submits" @@ -614,6 +670,8 @@ erDiagram org_terms ||--o{ org_placements : "linked to" org_enrollments ||--o{ org_placements : "has" + ordo ||--o{ events : "linked to" + events ||--o{ rsvp : "receives" events ||--o{ attendances : "records" @@ -713,6 +771,17 @@ flowchart TD E --> F[Return viewerUrl to client] ``` +### 4.6 Ordo Seeding Flow + +```mermaid +flowchart TD + A[Cron / manual trigger] --> B[SyncClient scrapes lagumisa.web.id/saranps.php] + B --> C[Write raw data to sync_staging.liturgi] + C --> D[Transform: map color & rank to English enums] + D --> E[Upsert into ordo\nON CONFLICT date + massLabel DO UPDATE] + E --> F[source = lagumisa\ncreatedBy = null] +``` + --- ## 5. Soft Delete Strategy @@ -774,6 +843,26 @@ Stores raw sacrament data scraped from DUK. Imported as-is โ€” not transformed i | `rawData` | jsonb | Full raw sacrament data | | `scrapedAt` | timestamptz | Default: `NOW()` | +#### `sync_staging.liturgi` + +Stores raw liturgical calendar data scraped from lagumisa.web.id. Transformed into the `ordo` table. + +| Field | Type | Description | +|---|---|---| +| `id` | serial | Primary key | +| `celebrationName` | text | Raw celebration name from HTML | +| `month` | text | Month name (e.g., `"Desember"`) | +| `dayName` | text | Day name (e.g., `"Minggu"`) | +| `dateNumber` | integer | Date number (e.g., `25`) | +| `isSunday` | boolean | `true` if `class="iconminggu"` on `