From 37fa636713b34ecdee4ceb13971d1fb3d59c20ea Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Sun, 12 Apr 2026 20:39:34 +0800
Subject: [PATCH 01/13] docs: define ordo feature and database schema in PRD
and ERD
- [docs/prd]: add Ordo (Liturgical Calendar) as MVP feature
- [docs/prd]: define user stories, ranks, and liturgical colors
- [docs/erd]: add sync_staging.liturgi table definition
- [docs/erd]: add ordo sync transform flowchart
---
docs/erd.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++----
docs/prd.md | 51 +++++++++++++++++++++++---
2 files changed, 141 insertions(+), 11 deletions(-)
diff --git a/docs/erd.md b/docs/erd.md
index 4dc5f22..3f24649 100644
--- a/docs/erd.md
+++ b/docs/erd.md
@@ -4,9 +4,9 @@
---
-**Document Version:** 1.2.0
+**Document Version:** 1.3.0
**Status:** In Progress
-**Last Updated:** 11 April 2026
+**Last Updated:** 12 April 2026
---
@@ -131,6 +131,32 @@ export const NotificationStatus = {
} as const
```
+### Ordo (Liturgical Calendar)
+
+```typescript
+export const CelebrationRank = {
+ Solemnity: 'solemnity',
+ Feast: 'feast',
+ Memorial: 'memorial',
+ Commemoration: 'commemoration',
+ Feria: 'feria',
+} as const
+
+export const LiturgicalColor = {
+ Purple: 'purple',
+ White: 'white',
+ Red: 'red',
+ Green: 'green',
+ Rose: 'rose',
+ Black: 'black',
+} as const
+
+export const OrdoSource = {
+ Lagumisa: 'lagumisa',
+ Manual: 'manual',
+} as const
+```
+
---
## 2. Entities & Table Schemas
@@ -422,7 +448,35 @@ Represents kelurahan/desa level.
---
-### 2.5 Events & Attendance
+### 2.5 Ordo (Liturgical Calendar)
+
+#### `ordo`
+
+Master liturgical calendar. One record per celebration per day. For days with multiple masses (e.g., Christmas: Midnight, Dawn, Day), each mass is a separate record distinguished by `massLabel`.
+
+| Field | Type | Description |
+|---|---|---|
+| `id` | uuid v7 | Primary key |
+| `date` | date | Liturgical date. Required. |
+| `name` | string | Celebration name. e.g. `"HARI MINGGU ADVEN IV"`. Required. |
+| `rank` | CelebrationRank | `solemnity` \| `feast` \| `memorial` \| `commemoration` \| `feria`. Required. |
+| `color` | LiturgicalColor | `purple` \| `white` \| `red` \| `green` \| `rose` \| `black`. Required. |
+| `massLabel` | string? | Mass label for days with multiple masses. e.g. `"Misa Malam"`, `"Misa Fajar"`, `"Misa Siang"`. Null if only one mass. |
+| `readings` | text[] | Array of scripture reading references. e.g. `["Yes. 9:1-6", "Mzm. 96:1-2a", "Tit. 2:11-14", "Luk. 2:1-14"]` |
+| `songs` | string? | Raw Puji Syukur song number suggestions. e.g. `"PS 451, 452, 453, 454"` |
+| `source` | OrdoSource | `lagumisa` \| `manual`. Default: `manual`. |
+| `createdBy` | text? | FK → `users.id`. Null if seeded from scraper. |
+| `createdAt` | datetime | |
+| `updatedAt` | datetime | |
+| `deletedAt` | datetime? | Soft delete |
+
+**Constraint:** `UNIQUE (date, massLabel)` — prevents duplicate entries for the same mass on the same day. `massLabel` uses `NULLS NOT DISTINCT` so null is treated as a unique value per date.
+
+> **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 `` element |
+| `liturgicalColor` | text | Raw color id from HTML (e.g., `"ccungu"`, `"ccputih"`) |
+| `massLabel` | text? | Sub-mass label if applicable (e.g., `"Misa Malam"`) |
+| `readings` | text[] | Array of raw scripture reference strings |
+| `songs` | text? | Raw Puji Syukur song suggestion string |
+| `scrapedAt` | timestamptz | Default: `NOW()` |
+
+**Constraint:** `UNIQUE (celebration_name, month, date_number, mass_label)` with `NULLS NOT DISTINCT`
+
---
*This document is a living document and will be updated as the Domus database schema evolves.*
diff --git a/docs/prd.md b/docs/prd.md
index 9624896..761e1b7 100644
--- a/docs/prd.md
+++ b/docs/prd.md
@@ -4,9 +4,9 @@
---
-**Document Version:** 1.0.12
+**Document Version:** 1.0.13
**Status:** In Progress
-**Last Updated:** 10 April 2026
+**Last Updated:** 12 April 2026
---
@@ -24,6 +24,7 @@ Kristus Raja Barong Tongkok Parish currently manages all administration manually
- Make it easier for parishioners to independently record their attendance at organizational events.
- Provide the parish treasurer with a structured and documented financial recording tool.
- Provide the pastor and executive board with transparent and easy-to-understand financial reports.
+- Provide a centralized liturgical calendar reference to support parish planning and ministry scheduling.
### 2.1 Success Metrics (KPIs)
@@ -38,12 +39,13 @@ To measure the success of the Domus MVP implementation, the following indicators
## 3. MVP Scope
-The Domus MVP covers four main features:
+The Domus MVP covers five main features:
1. **Registration & Enrollment Management** — parishioners can register for organizations independently, and admins can manage enrollment data.
2. **Parishioner Self-Attendance** — parishioners can record their own attendance at organizational activities.
3. **Parish Financial Recording** — treasurers can record parish income and expenses.
4. **Financial Reports** — pastors and the executive board can access financial reports digitally.
+5. **Ordo (Liturgical Calendar)** — a centralized reference for daily liturgical celebrations, readings, and song suggestions, seeded from lagumisa.web.id and manageable by admins.
---
@@ -56,11 +58,11 @@ A single user can have more than one role simultaneously. The default role for a
| UserRole | Description |
|---|---|
| **Super Admin** (`super-admin`) | System management: user management, transaction categories, and `joinId` rotation. No access to parish features. |
-| **Parish Admin** (`parish-admin`) | Creates and manages parish-level organizations and public events (e.g., mass, retreats). |
+| **Parish Admin** (`parish-admin`) | Creates and manages parish-level organizations and public events (e.g., mass, retreats). Manages ordo entries. |
| **Treasurer** (`treasurer`) | Records transactions, manages categories, and locks/unlocks financial periods. |
| **Pastor** (`pastor`) | Views financial reports (read-only access). |
| **Executive Board** (`executive-board`) | Daily administrators with access to financial reports. |
-| **Parishioner** (`parishioner`) | Default role for all users; allowed to RSVP and perform self-attendance at events. |
+| **Parishioner** (`parishioner`) | Default role for all users; allowed to RSVP and perform self-attendance at events. Can view the ordo. |
### 4.2 Scoped Roles (per Organization)
@@ -262,6 +264,41 @@ flowchart TD
---
+### 6.5 Ordo (Liturgical Calendar)
+
+**User Story:** *As a parish admin, I want a centralized liturgical calendar so that I can reference daily celebrations, readings, and song suggestions when planning parish events and ministry schedules.*
+
+**Acceptance Criteria:**
+- Ordo data is seeded automatically from lagumisa.web.id via the sync pipeline.
+- Parish admins can add, edit, or soft-delete ordo entries manually.
+- Each entry shows: date, celebration name, rank, liturgical color, readings, and song suggestions.
+- Entries can be linked to `events` to associate a mass or parish activity with its liturgical context.
+- All parishioners can view the ordo (read-only).
+
+#### Data Source
+
+Ordo data is scraped from [lagumisa.web.id/saranps.php](https://www.lagumisa.web.id/saranps.php) via `packages/sync`. The scraper runs on demand or via cron and upserts into the `ordo` table. Admin manual entries take `source: 'manual'` and are never overwritten by the scraper.
+
+#### Celebration Ranks
+
+| Rank | Description |
+|---|---|
+| `solemnity` | Hari Raya — highest rank |
+| `feast` | Pesta |
+| `memorial` | Peringatan Wajib |
+| `commemoration` | Peringatan Fakultatif |
+| `feria` | Hari Biasa |
+
+#### Liturgical Colors
+
+`purple`, `white`, `red`, `green`, `rose`, `black`
+
+#### Multiple Masses per Day
+
+Some celebrations have multiple masses (e.g., Christmas: Midnight, Dawn, Day). Each mass is stored as a separate `ordo` record with the same `date` but a different `massLabel`.
+
+---
+
## 7. Authentication & Access
- The system uses **Google OAuth** as the sole authentication method.
@@ -292,12 +329,14 @@ flowchart TD
| **Vercel** | Application hosting platform. |
| **Neon PostgreSQL** | Primary application database. |
| **Cloudflare Workers** | Cron job for `joinId` rotation. |
+| **lagumisa.web.id** | External source for ordo seeding. Read-only scraping — no API key required. |
### 8.3 Technical Risks
- **GPS Accuracy:** In remote station areas, GPS signals may be unstable, potentially increasing the admin's workload for approving manual attendance submissions.
- **Third-Party Dependency:** Full reliance on Google OAuth and Google Drive means if those services go *down*, login and verification functions will halt.
- **JoinId Rotation:** Periodic link changes may confuse parishioners if old links are still being shared; clear error messages and a flow for requesting a new link are required.
+- **lagumisa.web.id Structure Changes:** The ordo scraper depends on the HTML structure of lagumisa.web.id. If the site changes its markup, the scraper will break and require maintenance. Manual entry remains available as a fallback.
---
@@ -317,6 +356,7 @@ flowchart TD
| **Google Drive** | Private file storage: ID card photos (verification), transaction receipts, event attachments, org documents, SK documents. Accessed via Google Drive Viewer URL. |
| **Cloudflare R2** | Public asset storage: org logos, org covers, user profile photos (custom uploads). Served via direct public URL. |
| **Cloudflare Workers** | Cron job for `joinId` rotation |
+| **lagumisa.web.id** | Ordo data source — liturgical calendar scraping |
### 10.1 Storage Responsibility
@@ -346,6 +386,7 @@ The following features are **not included** in the MVP and will be considered in
- Multi-parish support
- Push notifications
- Financial report export (PDF/Excel)
+- Liturgical ministry scheduling (linked to ordo — post-MVP)
### 11.1 Non-Goals
From 5f86d26559064883df357aca28ea414e455124cc Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Sun, 12 Apr 2026 23:14:59 +0800
Subject: [PATCH 02/13] feat(dash): implement diocese list page and management
infrastructure
- Build diocese list page at src/pages/setting/ui/DioceseListPage.tsx.
- Create thin export at app/(dash)/setting/diocese/page.tsx.
- Implement PremiumSearch shared component.
- Wire up diocese list fetching and server actions.
- Update core diocese service and repository.
- Add i18n translations for diocese management.
Closes #139
---
apps/dash/app/(dash)/setting/diocese/page.tsx | 1 +
.../e2e/features/setting/diocese/list.spec.ts | 70 ++++++++
apps/dash/e2e/helper/diocese.ts | 53 ++++++
.../dash/e2e/pages/setting/DioceseListPage.ts | 68 ++++++++
.../dash/src/pages/setting/actions/diocese.ts | 11 +-
.../src/pages/setting/ui/DioceseListPage.tsx | 23 +++
.../setting/ui/components/DioceseCard.tsx | 161 ++++++++++++++++++
.../setting/ui/components/DioceseList.tsx | 89 ++++++++++
.../ui/components/DioceseListContent.tsx | 115 +++++++++++++
apps/dash/src/shared/config/actions.ts | 2 +-
apps/dash/src/shared/i18n/messages/en.json | 9 +-
apps/dash/src/shared/i18n/messages/id.json | 9 +-
.../shared/ui/components/PremiumSearch.tsx | 104 +++++++++++
apps/dash/src/shared/ui/components/index.ts | 1 +
packages/core/src/contract/diocese.ts | 6 +-
packages/core/src/service/diocese.ts | 11 +-
packages/db/src/repository/diocese.spec.ts | 13 ++
packages/db/src/repository/diocese.ts | 25 ++-
scripts/github/update-status.py | 105 ++++++++----
19 files changed, 820 insertions(+), 56 deletions(-)
create mode 100644 apps/dash/app/(dash)/setting/diocese/page.tsx
create mode 100644 apps/dash/e2e/features/setting/diocese/list.spec.ts
create mode 100644 apps/dash/e2e/helper/diocese.ts
create mode 100644 apps/dash/e2e/pages/setting/DioceseListPage.ts
create mode 100644 apps/dash/src/pages/setting/ui/DioceseListPage.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/DioceseList.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
create mode 100644 apps/dash/src/shared/ui/components/PremiumSearch.tsx
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/e2e/features/setting/diocese/list.spec.ts b/apps/dash/e2e/features/setting/diocese/list.spec.ts
new file mode 100644
index 0000000..c662b34
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/list.spec.ts
@@ -0,0 +1,70 @@
+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(page.getByTestId("search-clear")).toBeVisible();
+
+ // Clear search and verify list is restored
+ await page.getByTestId("search-clear").click();
+ await page.waitForTimeout(1000); // Wait for debounce
+ 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/helper/diocese.ts b/apps/dash/e2e/helper/diocese.ts
new file mode 100644
index 0000000..17bc927
--- /dev/null
+++ b/apps/dash/e2e/helper/diocese.ts
@@ -0,0 +1,53 @@
+import type { CreateDiocese } from "@domus/core";
+import service from "@/shared/core";
+
+/**
+ * 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
+ const [dioceses] = await service.diocese.findAll(
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as any, // 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 service.diocese.create(
+ withDefaults,
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as any, // Bypass auth check for seeding
+ );
+
+ if (error) throw error;
+ diocese = created;
+ }
+
+ return diocese;
+}
diff --git a/apps/dash/e2e/pages/setting/DioceseListPage.ts b/apps/dash/e2e/pages/setting/DioceseListPage.ts
new file mode 100644
index 0000000..8fafd24
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseListPage.ts
@@ -0,0 +1,68 @@
+import type { Locator, 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 addDioceseBtn: Locator;
+ readonly dioceseGrid: Locator;
+ readonly dioceseCards: Locator;
+ readonly emptyState: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.searchInput = page.getByTestId("search-input");
+ 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");
+ }
+
+ /**
+ * Navigates to the diocese list page.
+ */
+ async goto() {
+ await this.page.goto("/setting/diocese");
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * Performs a search by filling the search input.
+ *
+ * @param query - The search query.
+ */
+ async search(query: string) {
+ await this.searchInput.fill(query);
+ // Wait for the debounce and URL sync to complete
+ await this.page.waitForTimeout(1000);
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * 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/src/pages/setting/actions/diocese.ts b/apps/dash/src/pages/setting/actions/diocese.ts
index 1bb2c53..b49b57f 100644
--- a/apps/dash/src/pages/setting/actions/diocese.ts
+++ b/apps/dash/src/pages/setting/actions/diocese.ts
@@ -163,18 +163,21 @@ export async function getDioceseAction(id: string): Promise> {
}
/**
- * 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/ui/DioceseListPage.tsx b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx
new file mode 100644
index 0000000..5d23c8d
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx
@@ -0,0 +1,23 @@
+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;
+
+ // Initiate action - returns a promise
+ const promise = listDiocesesAction(q);
+
+ 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..1c30953
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
@@ -0,0 +1,161 @@
+"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 { cn } from "@/shared/ui/common/utils";
+import { Badge } from "@/shared/ui/shadcn/badge";
+import { Button, 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) {
+ return (
+
+
+
+
+
+
+ Diocese
+
+
+ {diocese.name}
+
+
+ {diocese.logo && (
+
+
+
+ )}
+
+
+ {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"}`}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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..85f08e8
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
@@ -0,0 +1,115 @@
+"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 { PremiumSearch } from "@/shared/ui/components/PremiumSearch";
+import { Button } from "@/shared/ui/shadcn/button";
+import { DioceseList } from "./DioceseList";
+
+/**
+ * Props for the DioceseListContent component.
+ */
+interface DioceseListContentProps {
+ /** Initial promise from server. */
+ promise: Promise>;
+}
+
+/**
+ * Client-side layout and state management for the Diocese list page.
+ * Handles URL-based search filtering and data presentation.
+ */
+export function DioceseListContent({ promise }: 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 (
+
+ {/* Header Section */}
+
+
+
+
+ Administration
+
+
+ {t("title")}
+
+
+ {t("description")}
+
+
+
+
+
+ {t("addDiocese")}
+
+
+
+ {/* Filter Bar */}
+
+
+ {/* List Section */}
+
+ {[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..62b5788 100644
--- a/apps/dash/src/shared/config/actions.ts
+++ b/apps/dash/src/shared/config/actions.ts
@@ -83,6 +83,6 @@ export const DASHBOARD_ACTIONS: LayananItem[] = [
icon: Church,
color: "bg-[#0288d1] text-white",
hoverTextColor: "hover:text-[#0288d1]",
- href: "/diocese",
+ href: "/setting/diocese",
},
];
diff --git a/apps/dash/src/shared/i18n/messages/en.json b/apps/dash/src/shared/i18n/messages/en.json
index d26922d..42b2958 100644
--- a/apps/dash/src/shared/i18n/messages/en.json
+++ b/apps/dash/src/shared/i18n/messages/en.json
@@ -69,11 +69,16 @@
"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."
},
+ "Common": {
+ "searchPlaceholder": "Search..."
+ },
"DioceseForm": {
"nameLabel": "Diocese Name",
"namePlaceholder": "Enter diocese name...",
diff --git a/apps/dash/src/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json
index 167ad80..8461fb4 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -69,11 +69,16 @@
"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."
},
+ "Common": {
+ "searchPlaceholder": "Cari..."
+ },
"DioceseForm": {
"nameLabel": "Nama Keuskupan",
"namePlaceholder": "Masukkan nama keuskupan...",
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..963fe85
--- /dev/null
+++ b/apps/dash/src/shared/ui/components/PremiumSearch.tsx
@@ -0,0 +1,104 @@
+"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/index.ts b/apps/dash/src/shared/ui/components/index.ts
index f2dda3b..436515b 100644
--- a/apps/dash/src/shared/ui/components/index.ts
+++ b/apps/dash/src/shared/ui/components/index.ts
@@ -2,6 +2,7 @@ export * from "./DomusCard";
export * from "./LegalDialog";
export * from "./PremiumAction";
export * from "./PremiumFooter";
+export * from "./PremiumSearch";
export * from "./PrivacyView";
export * from "./Providers";
export * from "./TermsView";
diff --git a/packages/core/src/contract/diocese.ts b/packages/core/src/contract/diocese.ts
index 363ee78..35173ee 100644
--- a/packages/core/src/contract/diocese.ts
+++ b/packages/core/src/contract/diocese.ts
@@ -10,9 +10,11 @@ export interface IDioceseRepository {
findById(id: string): Promise;
/**
- * Finds all Dioceses.
+ * Finds all Dioceses, optionally filtered by a search query.
+ *
+ * @param q - Optional search query to filter dioceses by name or address.
*/
- findAll(): Promise;
+ findAll(q?: string): Promise;
/**
* Creates a new Diocese record.
diff --git a/packages/core/src/service/diocese.ts b/packages/core/src/service/diocese.ts
index 699e164..f58e590 100644
--- a/packages/core/src/service/diocese.ts
+++ b/packages/core/src/service/diocese.ts
@@ -42,11 +42,14 @@ export class DioceseService {
}
/**
- * Finds all dioceses.
+ * Finds all dioceses, optionally filtered by a search query.
* Requires the user to be an approved member.
+ *
+ * @param ctx - Use AuthContext to verify approval status.
+ * @param q - Optional search query.
*/
- async findAll(ctx: AuthContext): Promise> {
- this.logger.info('DioceseService.findAll', { userId: ctx.userId });
+ async findAll(ctx: AuthContext, q?: string): Promise> {
+ this.logger.info('DioceseService.findAll', { userId: ctx.userId, q });
if (ctx.accountStatus !== AccountStatus.Approved) {
this.logger.warn('DioceseService.findAll: unauthorized', {
@@ -55,7 +58,7 @@ export class DioceseService {
return fail(new ForbiddenError('Only approved users can view dioceses.'));
}
- const dioceses = await this.repo.findAll();
+ const dioceses = await this.repo.findAll(q);
return ok(dioceses);
}
diff --git a/packages/db/src/repository/diocese.spec.ts b/packages/db/src/repository/diocese.spec.ts
index 32bf89b..3a7d838 100644
--- a/packages/db/src/repository/diocese.spec.ts
+++ b/packages/db/src/repository/diocese.spec.ts
@@ -74,6 +74,19 @@ describe('DioceseRepository', () => {
const result = await repo.findAll();
expect(result).toEqual([mockDioceseEntity]);
});
+
+ it('should filter dioceses by query', async () => {
+ // @ts-expect-error
+ dbMock.select.mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([mockDioceseRow]),
+ }),
+ });
+
+ const result = await repo.findAll('Samarinda');
+ expect(result).toEqual([mockDioceseEntity]);
+ expect(dbMock.select).toHaveBeenCalled();
+ });
});
describe('create', () => {
diff --git a/packages/db/src/repository/diocese.ts b/packages/db/src/repository/diocese.ts
index 0f2f300..9d66f78 100644
--- a/packages/db/src/repository/diocese.ts
+++ b/packages/db/src/repository/diocese.ts
@@ -6,7 +6,7 @@ import {
type ILogger,
type UpdateDiocese,
} from '@domus/core';
-import { and, eq, isNull } from 'drizzle-orm';
+import { and, eq, ilike, isNull, or } from 'drizzle-orm';
import type { DrizzleClient } from '../index';
import { dioceses } from '../schema/dioceses';
@@ -38,14 +38,29 @@ export class DioceseRepository implements IDioceseRepository {
}
/**
- * Finds all dioceses.
+ * Finds all dioceses, optionally filtered by name or address.
+ *
+ * @param q - Optional search query.
*/
- async findAll(): Promise {
- this.logger.info('DioceseRepository.findAll');
+ async findAll(q?: string): Promise {
+ this.logger.info('DioceseRepository.findAll', { q });
+
+ const conditions = [isNull(dioceses.deletedAt)];
+
+ if (q) {
+ const searchCondition = or(
+ ilike(dioceses.name, `%${q}%`),
+ ilike(dioceses.address, `%${q}%`),
+ );
+ if (searchCondition) {
+ conditions.push(searchCondition);
+ }
+ }
+
const rows = await this.db
.select()
.from(dioceses)
- .where(isNull(dioceses.deletedAt));
+ .where(and(...conditions));
return rows.map((row) => DioceseEntity.parse(row));
}
diff --git a/scripts/github/update-status.py b/scripts/github/update-status.py
index 279b8b6..96614b6 100755
--- a/scripts/github/update-status.py
+++ b/scripts/github/update-status.py
@@ -27,12 +27,36 @@ def get_issue_body(issue_number, owner, repo):
if result.returncode != 0: return None
return json.loads(result.stdout)["body"]
-def mark_checkboxes(body):
- """Marks all unchecked markdown checkboxes as checked."""
- return re.sub(r"- \[ \]", "- [x]", body)
+def mark_checkboxes(body, task_pattern=None):
+ """
+ Marks checkboxes as checked.
+ If task_pattern is provided, only marks checkboxes on lines containing that pattern.
+ If task_pattern is None, marks all checkboxes as checked.
+ """
+ if not task_pattern:
+ return re.sub(r"- \[ \]", "- [x]", body)
+
+ lines = body.splitlines()
+ new_lines = []
+ task_found = False
+ for line in lines:
+ if task_pattern in line and "- [ ]" in line:
+ new_lines.append(line.replace("- [ ]", "- [x]"))
+ task_found = True
+ else:
+ new_lines.append(line)
+
+ if task_pattern and not task_found:
+ print(f"⚠️ Task pattern '{task_pattern}' not found or already checked.", file=sys.stderr)
+
+ return "\n".join(new_lines)
def update_issue_body(issue_number, owner, repo, new_body):
"""Saves the new body to the issue."""
+ # Ensure there's a trailing newline if the original had one or to be safe
+ if not new_body.endswith('\n'):
+ new_body += '\n'
+
with open("temp_body.md", "w") as f:
f.write(new_body)
subprocess.run(["gh", "issue", "edit", str(issue_number), "-R", f"{owner}/{repo}", "--body-file", "temp_body.md"])
@@ -120,7 +144,8 @@ def perform_status_update(project_id, item_id, field_id, option_id):
def main():
parser = argparse.ArgumentParser(description="Automate GitHub issue status and checkbox updates via targeted GraphQL.")
parser.add_argument("issue", type=int, help="Issue number")
- parser.add_argument("status", help="Target status (e.g., backlog, ready, progress, review, done)")
+ parser.add_argument("status", nargs="?", help="Target status (e.g., backlog, ready, progress, review, done). Optional if --task is used.")
+ parser.add_argument("-t", "--task", help="Specific task ID or pattern to mark as completed (e.g., ST-01)")
parser.add_argument("--owner", default=DEFAULT_OWNER, help=f"Repo owner (default: {DEFAULT_OWNER})")
parser.add_argument("--repo", default=DEFAULT_REPO, help=f"Repo name (default: {DEFAULT_REPO})")
parser.add_argument("--project-num", type=int, default=DEFAULT_PROJECT_NUM, help=f"Project number (default: {DEFAULT_PROJECT_NUM})")
@@ -128,48 +153,56 @@ def main():
args = parser.parse_args()
- status_map = {
- "backlog": "Backlog",
- "ready": "Ready",
- "progress": "In progress",
- "in-progress": "In progress",
- "review": "In review",
- "in-review": "In review",
- "done": "Done"
- }
- status_target = status_map.get(args.status.lower(), args.status)
-
- print(f"🚀 Automating Issue #{args.issue} -> '{status_target}' (via API)")
+ if not args.status and not args.task:
+ parser.error("At least one of 'status' or '--task' must be provided.")
# 1. Update Checkboxes in Body
if not args.skip_body:
- print("📝 Updating issue body checkboxes...")
+ print(f"📝 Updating issue #{args.issue} body checkboxes...")
body = get_issue_body(args.issue, args.owner, args.repo)
if body:
- new_body = mark_checkboxes(body)
+ new_body = mark_checkboxes(body, args.task)
if new_body != body:
update_issue_body(args.issue, args.owner, args.repo, new_body)
- print("✅ Checkboxes marked.")
+ if args.task:
+ print(f"✅ Task '{args.task}' marked as completed.")
+ else:
+ print("✅ All checkboxes marked as completed.")
else:
- print("ℹ️ No checkboxes to update.")
+ print("ℹ️ Body already up to date.")
# 2. Update Project Status using GraphQL
- print(f"🔍 Fetching targeted IDs for Issue #{args.issue} in Project #{args.project_num}...")
- project_id, item_id, field_id, option_id = get_target_ids(args.owner, args.repo, args.issue, args.project_num, status_target)
-
- if not item_id:
- print(f"❌ Issue #{args.issue} NOT linked to Project #{args.project_num}.")
- sys.exit(1)
- if not field_id or not option_id:
- print(f"❌ Could not find 'Status' field or '{status_target}' option.")
- sys.exit(1)
-
- print(f"🔄 Moving item to '{status_target}'...")
- if perform_status_update(project_id, item_id, field_id, option_id):
- print(f"✨ Successfully moved to '{status_target}'!")
- else:
- print("❌ GraphQL mutation failed.")
- sys.exit(1)
+ if args.status:
+ status_map = {
+ "backlog": "Backlog",
+ "ready": "Ready",
+ "progress": "In progress",
+ "in-progress": "In progress",
+ "review": "In review",
+ "in-review": "In review",
+ "done": "Done"
+ }
+ status_target = status_map.get(args.status.lower(), args.status)
+
+ print(f"🔍 Fetching targeted IDs for Issue #{args.issue} in Project #{args.project_num}...")
+ project_id, item_id, field_id, option_id = get_target_ids(args.owner, args.repo, args.issue, args.project_num, status_target)
+
+ if not item_id:
+ print(f"❌ Issue #{args.issue} NOT linked to Project #{args.project_num}.")
+ sys.exit(1)
+ if not field_id or not option_id:
+ print(f"❌ Could not find 'Status' field or '{status_target}' option.")
+ sys.exit(1)
+
+ print(f"🔄 Moving item to '{status_target}'...")
+ if perform_status_update(project_id, item_id, field_id, option_id):
+ print(f"✨ Successfully moved to '{status_target}'!")
+ else:
+ print("❌ GraphQL mutation failed.")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
if __name__ == "__main__":
main()
From f005ca06b152263ca7601f39c81a3b3ac79b4403 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Sun, 12 Apr 2026 23:53:01 +0800
Subject: [PATCH 03/13] feat(diocese): implement create page and e2e testing
- Created DioceseForm with TanStack Form and Zod validation
- Implemented DioceseCreatePage with premium DomusCard components
- Added POM for DioceseCreatePage and comprehensive E2E test suite
- Linked Diocese list page with creation flow
Closes #139
---
.../app/(dash)/setting/diocese/new/page.tsx | 1 +
.../features/setting/diocese/create.spec.ts | 78 ++++++
.../e2e/pages/setting/DioceseCreatePage.ts | 80 ++++++
.../pages/setting/ui/DioceseCreatePage.tsx | 36 +++
.../setting/ui/components/DioceseForm.tsx | 247 ++++++++++++++++++
.../ui/components/DioceseListContent.tsx | 1 +
apps/dash/src/shared/i18n/messages/en.json | 20 ++
apps/dash/src/shared/i18n/messages/id.json | 20 ++
8 files changed, 483 insertions(+)
create mode 100644 apps/dash/app/(dash)/setting/diocese/new/page.tsx
create mode 100644 apps/dash/e2e/features/setting/diocese/create.spec.ts
create mode 100644 apps/dash/e2e/pages/setting/DioceseCreatePage.ts
create mode 100644 apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
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/e2e/features/setting/diocese/create.spec.ts b/apps/dash/e2e/features/setting/diocese/create.spec.ts
new file mode 100644
index 0000000..23f8e23
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/create.spec.ts
@@ -0,0 +1,78 @@
+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 expect(page).toHaveURL(/\/setting\/diocese\/new$/);
+
+ // 3. Verify page header
+ // We wait for the heading to be visible and have the right text
+ const heading = page.getByRole("heading", { level: 1 });
+ 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 and appearance in list
+ await expect(page).toHaveURL(/\/setting\/diocese$/, { timeout: 10000 });
+ 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 page.waitForLoadState("networkidle");
+
+ // 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/pages/setting/DioceseCreatePage.ts b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
new file mode 100644
index 0000000..2f06bcf
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
@@ -0,0 +1,80 @@
+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.getByRole("heading", { level: 1 });
+ }
+
+ /**
+ * Navigates to the diocese creation page.
+ */
+ async goto() {
+ await this.page.goto("/setting/diocese/new");
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * 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();
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * Cancels the diocese creation.
+ */
+ async cancel() {
+ await this.cancelBtn.click();
+ await this.page.waitForLoadState("networkidle");
+ }
+}
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..17a3a17
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
@@ -0,0 +1,36 @@
+import { getTranslations } from "next-intl/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 cathedralImage =
+ "https://images.unsplash.com/photo-1548678912-4192d595604b?auto=format&fit=crop&q=80&w=1200";
+
+ return (
+
+
+
+
+
+ {t("title")}
+
+
+ {t("description")}
+
+
+
+
+ );
+}
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..b34584a
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import {
+ type CreateDiocese,
+ CreateDioceseSchema,
+ type Diocese,
+ type Result,
+} from "@domus/core";
+import {
+ 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 { logger } from "@/shared/core/logger";
+import { DomusCardContent } from "@/shared/ui/components/DomusCard";
+import ValidationErrors from "@/shared/ui/components/ValidationErrors";
+import { useDomusForm } from "@/shared/ui/fields/context";
+import { Button } from "@/shared/ui/shadcn/button";
+
+interface DioceseFormProps {
+ action: (data: CreateDiocese) => Promise>;
+}
+
+/**
+ * Reusable form component for creating or updating a Diocese.
+ * Uses useDomusForm for premium field binding and validation.
+ */
+export function DioceseForm({ action }: DioceseFormProps) {
+ const t = useTranslations("DioceseForm");
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [errorMsg, setErrorMsg] = useState(null);
+
+ const form = useDomusForm({
+ defaultValues: {
+ name: "",
+ address: "",
+ phone: "",
+ email: undefined,
+ website: undefined,
+ logo: "",
+ description: "",
+ } as CreateDiocese,
+ validators: {
+ onChange: CreateDioceseSchema,
+ onBlur: CreateDioceseSchema,
+ },
+ onSubmit: async ({ value }) => {
+ setIsSubmitting(true);
+ setErrorMsg(null);
+ const [data, error] = await action(value);
+ setIsSubmitting(false);
+
+ if (error) {
+ setErrorMsg(error.message);
+ logger.error("[DioceseForm] Submission failed", { error, value });
+ return;
+ }
+
+ if (data) {
+ router.push("/setting/diocese");
+ router.refresh();
+ }
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
index 85f08e8..da84800 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
@@ -75,6 +75,7 @@ export function DioceseListContent({ promise }: DioceseListContentProps) {
router.push("/setting/diocese/new")}
className="h-12 px-6 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg shadow-primary/20 transition-all duration-300 gap-2 font-bold group"
>
diff --git a/apps/dash/src/shared/i18n/messages/en.json b/apps/dash/src/shared/i18n/messages/en.json
index 42b2958..2ca5640 100644
--- a/apps/dash/src/shared/i18n/messages/en.json
+++ b/apps/dash/src/shared/i18n/messages/en.json
@@ -76,12 +76,32 @@
"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"
},
diff --git a/apps/dash/src/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json
index 8461fb4..4ca80b6 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -76,12 +76,32 @@
"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"
},
From 332b6f7848624c1124d1432651e49f90c9eeb0da Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 00:13:14 +0800
Subject: [PATCH 04/13] feat(dash): implement diocese detail and edit page
- Refactor DioceseForm to support both create and update flows
- Implement DioceseDetailPage with premium UI and glassmorphism header
- Add soft-delete confirmation with AlertDialog and sonner toasts
- Integrate RBAC gating for editing and deletion permissions
- Add E2E tests for update and soft-delete flows
- Clean up explicit any usage for compliance
Closes #139
---
.../app/(dash)/setting/diocese/[id]/page.tsx | 14 ++
.../features/setting/diocese/detail.spec.ts | 89 ++++++++++
apps/dash/e2e/helper/diocese.ts | 6 +-
.../e2e/pages/setting/DioceseDetailPage.ts | 86 +++++++++
.../pages/setting/ui/DioceseDetailPage.tsx | 90 ++++++++++
.../setting/ui/components/DioceseCard.tsx | 17 +-
.../setting/ui/components/DioceseForm.tsx | 165 +++++++++++++++---
apps/dash/src/shared/i18n/messages/id.json | 12 ++
8 files changed, 446 insertions(+), 33 deletions(-)
create mode 100644 apps/dash/app/(dash)/setting/diocese/[id]/page.tsx
create mode 100644 apps/dash/e2e/features/setting/diocese/detail.spec.ts
create mode 100644 apps/dash/e2e/pages/setting/DioceseDetailPage.ts
create mode 100644 apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx
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/e2e/features/setting/diocese/detail.spec.ts b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
new file mode 100644
index 0000000..b6f9f31
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
@@ -0,0 +1,89 @@
+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();
+
+ // 2. Click details on the newly created diocese
+ const card = page
+ .locator('[data-testid="diocese-card"]')
+ .filter({ hasText: originalName });
+ await card.getByRole("button", { name: /Details/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // 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();
+ const uniqueId = Date.now();
+ const nameToDelete = `Diocese to Delete ${uniqueId}`;
+ await createPage.fillForm({
+ name: nameToDelete,
+ });
+ await createPage.submit();
+
+ // 2. Go to details
+ const card = page
+ .locator('[data-testid="diocese-card"]')
+ .filter({ hasText: nameToDelete });
+ await card.getByRole("button", { name: /Details/i }).click();
+
+ // 3. Perform delete with confirmation
+ await detailPage.deleteAndConfirm();
+
+ // 4. Verify redirection to list and success toast
+ await expect(page).toHaveURL(/\/setting\/diocese$/);
+ await expect(
+ page.getByText(/berhasil dihapus|successfully deleted/i),
+ ).toBeVisible();
+
+ // 5. Verify it's no longer in the list (Soft Delete)
+ await expect(page.getByText(nameToDelete)).not.toBeVisible();
+ });
+});
diff --git a/apps/dash/e2e/helper/diocese.ts b/apps/dash/e2e/helper/diocese.ts
index 17bc927..64bf528 100644
--- a/apps/dash/e2e/helper/diocese.ts
+++ b/apps/dash/e2e/helper/diocese.ts
@@ -1,4 +1,4 @@
-import type { CreateDiocese } from "@domus/core";
+import type { AuthContext, CreateDiocese } from "@domus/core";
import service from "@/shared/core";
/**
@@ -28,7 +28,7 @@ export async function iHaveDiocese(payload: DiocesePayload) {
userId: "system",
roles: ["super-admin"],
accountStatus: "approved",
- } as any, // Bypass auth check for seeding
+ } as AuthContext, // Bypass auth check for seeding
withDefaults.name,
);
@@ -42,7 +42,7 @@ export async function iHaveDiocese(payload: DiocesePayload) {
userId: "system",
roles: ["super-admin"],
accountStatus: "approved",
- } as any, // Bypass auth check for seeding
+ } as AuthContext, // Bypass auth check for seeding
);
if (error) throw error;
diff --git a/apps/dash/e2e/pages/setting/DioceseDetailPage.ts b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
new file mode 100644
index 0000000..529bf7a
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
@@ -0,0 +1,86 @@
+import type { Locator, 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.getByRole("heading", { level: 1 });
+ }
+
+ /**
+ * Navigates to a specific diocese detail page.
+ * @param id - The diocese ID.
+ */
+ async goto(id: string) {
+ await this.page.goto(`/setting/diocese/${id}`);
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * 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();
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * Initiates deletion and confirms it in the dialog.
+ */
+ async deleteAndConfirm() {
+ await this.deleteBtn.click();
+ await this.confirmDeleteBtn.click();
+ await this.page.waitForLoadState("networkidle");
+ }
+}
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..c4be576
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx
@@ -0,0 +1,90 @@
+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 */}
+
+
+
+
+
+
+ {t("breadcrumb")}
+
+
+
+ {diocese.name}
+
+
+ {t("description")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
index 1c30953..32db91c 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
@@ -4,6 +4,7 @@ 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 Link from "next/link";
import { cn } from "@/shared/ui/common/utils";
import { Badge } from "@/shared/ui/shadcn/badge";
import { Button, buttonVariants } from "@/shared/ui/shadcn/button";
@@ -146,13 +147,15 @@ export function DioceseCard({ diocese }: DioceseCardProps) {
)}
-
- Details
-
+
+
+ Details
+
+
diff --git a/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
index b34584a..98bcd3b 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
@@ -7,6 +7,7 @@ import {
type Result,
} from "@domus/core";
import {
+ AlertTriangle,
Building2,
Check,
Globe,
@@ -18,35 +19,62 @@ import {
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 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";
import { Button } from "@/shared/ui/shadcn/button";
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 }: DioceseFormProps) {
+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: "",
- address: "",
- phone: "",
- email: undefined,
- website: undefined,
- logo: "",
- description: "",
+ 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,
@@ -55,22 +83,48 @@ export function DioceseForm({ action }: DioceseFormProps) {
onSubmit: async ({ value }) => {
setIsSubmitting(true);
setErrorMsg(null);
- const [data, error] = await action(value);
+ 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) {
- router.push("/setting/diocese");
+ toast.success(tDetail("successToast"));
+ if (!initialData) {
+ router.push("/setting/diocese");
+ }
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.push("/setting/diocese");
+ router.refresh();
+ };
+
return (
}
data-testid="diocese-name-input"
required
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -119,6 +174,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
placeholder={t("logoPlaceholder")}
icon={ }
data-testid="diocese-logo-input"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -135,6 +191,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
placeholder={t("descPlaceholder")}
data-testid="diocese-desc-input"
className="min-h-[120px]"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -158,6 +215,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
label={t("addressLabel")}
placeholder={t("addressPlaceholder")}
data-testid="diocese-address-input"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -174,6 +232,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
placeholder={t("phonePlaceholder")}
icon={ }
data-testid="diocese-phone-input"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -190,6 +249,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
icon={ }
data-testid="diocese-email-input"
type="email"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -206,6 +266,7 @@ export function DioceseForm({ action }: DioceseFormProps) {
placeholder={t("websitePlaceholder")}
icon={ }
data-testid="diocese-website-input"
+ disabled={isReadOnly}
/>
{field.state.meta.errors.length > 0 && (
@@ -217,19 +278,22 @@ export function DioceseForm({ action }: DioceseFormProps) {
{/* Actions */}
-
- {isSubmitting ? (
-
- ) : (
-
- )}
- {t("btnSave")}
-
+ {!isReadOnly && (
+
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {t("btnSave")}
+
+ )}
+
{t("btnCancel")}
+
+ {!isReadOnly && onDelete && (
+
+
+ setIsDeleteDialogOpen(true)}
+ disabled={isSubmitting || isDeleting}
+ data-testid="delete-diocese-btn"
+ className="h-12 px-6 rounded-2xl font-bold gap-2 bg-destructive/10 text-destructive border border-destructive/20 hover:bg-destructive hover:text-destructive-foreground transition-all shadow-none hover:shadow-lg hover:shadow-destructive/20"
+ >
+
+ {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/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json
index 4ca80b6..4904785 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -105,6 +105,18 @@
"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"
+ },
"OrgPage": {
"title": "Daftar Organisasi",
"description": "Temukan dan kelola komunitas rohani Anda di Domus.",
From 6184b675d014df78e4a9c50771d7c80813a46e67 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 00:19:59 +0800
Subject: [PATCH 05/13] feat(dash): add RBAC protection to diocese list and
create pages
- Hide 'Add Diocese' button for users without management permissions
- Add authorization check to DioceseCreatePage (Super/Parish Admin only)
- Complete GitHub issue #139 status updates and AC verification
---
.../pages/setting/ui/DioceseCreatePage.tsx | 25 ++++++++++++++++
.../src/pages/setting/ui/DioceseListPage.tsx | 12 +++++++-
.../ui/components/DioceseListContent.tsx | 29 ++++++++++++-------
3 files changed, 54 insertions(+), 12 deletions(-)
diff --git a/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
index 17a3a17..35cf6df 100644
--- a/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
+++ b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
@@ -1,4 +1,7 @@
+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,
@@ -15,6 +18,28 @@ import { DioceseForm } from "./components/DioceseForm";
*/
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
+
+ );
+ }
+
const cathedralImage =
"https://images.unsplash.com/photo-1548678912-4192d595604b?auto=format&fit=crop&q=80&w=1200";
diff --git a/apps/dash/src/pages/setting/ui/DioceseListPage.tsx b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx
index 5d23c8d..2534442 100644
--- a/apps/dash/src/pages/setting/ui/DioceseListPage.tsx
+++ b/apps/dash/src/pages/setting/ui/DioceseListPage.tsx
@@ -1,3 +1,5 @@
+import { UserRole } from "@domus/core";
+import { getAuthContext } from "@/shared/auth/server";
import { listDiocesesAction } from "../actions/diocese";
import { DioceseListContent } from "./components/DioceseListContent";
@@ -16,8 +18,16 @@ 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 ;
+ return ;
}
diff --git a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
index da84800..43e713c 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
@@ -15,13 +15,18 @@ import { DioceseList } from "./DioceseList";
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 }: DioceseListContentProps) {
+export function DioceseListContent({
+ promise,
+ canManage = false,
+}: DioceseListContentProps) {
const t = useTranslations("DiocesePage");
const router = useRouter();
const pathname = usePathname();
@@ -73,16 +78,18 @@ export function DioceseListContent({ promise }: DioceseListContentProps) {
- router.push("/setting/diocese/new")}
- className="h-12 px-6 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg shadow-primary/20 transition-all duration-300 gap-2 font-bold group"
- >
-
- {t("addDiocese")}
-
+ {canManage && (
+ router.push("/setting/diocese/new")}
+ className="h-12 px-6 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg shadow-primary/20 transition-all duration-300 gap-2 font-bold group"
+ >
+
+ {t("addDiocese")}
+
+ )}
{/* Filter Bar */}
From 580ca3c5e118f54f3f3eb4d48e6faf054aaf5a82 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 02:04:57 +0800
Subject: [PATCH 06/13] refactor(dash): stabilize diocese e2e tests and enhance
UI robustness
- Stabilize E2E tests: remove networkidle, add search state waits.
- UI: Use PremiumAction in DioceseForm and add test IDs for loading.
- Auth: Refactor proxy session handling to use getAuthSession tuple.
- POM: Update page objects to handle RBAC and improved transitions.
---
.../features/setting/diocese/create.spec.ts | 15 ++--
.../features/setting/diocese/detail.spec.ts | 42 +++++++----
.../e2e/features/setting/diocese/list.spec.ts | 5 +-
.../e2e/features/setting/diocese/rbac.spec.ts | 69 +++++++++++++++++++
apps/dash/e2e/helper/auth.ts | 19 +++++
.../e2e/pages/setting/DioceseCreatePage.ts | 5 +-
.../e2e/pages/setting/DioceseDetailPage.ts | 9 ++-
.../dash/e2e/pages/setting/DioceseListPage.ts | 37 ++++++++--
apps/dash/proxy.ts | 9 +--
.../pages/setting/ui/DioceseCreatePage.tsx | 27 +++++---
.../pages/setting/ui/DioceseDetailPage.tsx | 18 +++--
.../setting/ui/components/DioceseCard.tsx | 30 ++++----
.../setting/ui/components/DioceseForm.tsx | 46 ++++++-------
.../ui/components/DioceseListContent.tsx | 12 ++--
.../src/shared/ui/components/DomusCard.tsx | 8 ++-
.../shared/ui/components/PremiumSearch.tsx | 5 +-
packages/auth/src/index.ts | 7 ++
skills-lock.json | 10 +++
18 files changed, 265 insertions(+), 108 deletions(-)
create mode 100644 apps/dash/e2e/features/setting/diocese/rbac.spec.ts
diff --git a/apps/dash/e2e/features/setting/diocese/create.spec.ts b/apps/dash/e2e/features/setting/diocese/create.spec.ts
index 23f8e23..a177e8f 100644
--- a/apps/dash/e2e/features/setting/diocese/create.spec.ts
+++ b/apps/dash/e2e/features/setting/diocese/create.spec.ts
@@ -14,11 +14,11 @@ test.describe("Diocese Page: Create", () => {
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
- // We wait for the heading to be visible and have the right text
- const heading = page.getByRole("heading", { level: 1 });
+ const heading = page.getByTestId("page-title");
await expect(heading).toBeVisible({ timeout: 10000 });
await expect(heading).toContainText(/Tambah Keuskupan|New Diocese/i);
@@ -35,8 +35,13 @@ test.describe("Diocese Page: Create", () => {
await createPage.submit();
- // 5. Verify redirection and appearance in list
- await expect(page).toHaveURL(/\/setting\/diocese$/, { timeout: 10000 });
+ // 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();
});
@@ -63,7 +68,7 @@ test.describe("Diocese Page: Create", () => {
test("should navigate back when clicking cancel", async ({ page }) => {
// Start from list page to have history
await page.goto("/setting/diocese");
- await page.waitForLoadState("networkidle");
+ await expect(page.getByTestId("add-diocese-btn")).toBeVisible();
// Click add button (from list page)
await page.getByTestId("add-diocese-btn").click();
diff --git a/apps/dash/e2e/features/setting/diocese/detail.spec.ts b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
index b6f9f31..d072550 100644
--- a/apps/dash/e2e/features/setting/diocese/detail.spec.ts
+++ b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
@@ -5,12 +5,12 @@ import { DioceseDetailPage } from "../../../pages/setting/DioceseDetailPage";
import { DioceseListPage } from "../../../pages/setting/DioceseListPage";
test.describe("Diocese Page: Detail & Edit", () => {
- let _listPage: DioceseListPage;
+ let listPage: DioceseListPage;
let createPage: DioceseCreatePage;
let detailPage: DioceseDetailPage;
test.beforeEach(async ({ page, context }) => {
- _listPage = new DioceseListPage(page);
+ listPage = new DioceseListPage(page);
createPage = new DioceseCreatePage(page);
detailPage = new DioceseDetailPage(page);
await iHaveLoggedInAsSuperAdmin(context);
@@ -26,13 +26,18 @@ test.describe("Diocese Page: Detail & Edit", () => {
description: "Original description",
});
await createPage.submit();
+ await page.waitForURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/);
- // 2. Click details on the newly created diocese
- const card = page
- .locator('[data-testid="diocese-card"]')
- .filter({ hasText: originalName });
- await card.getByRole("button", { name: /Details/i }).click();
- await page.waitForLoadState("networkidle");
+ // 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-]+/);
@@ -61,27 +66,34 @@ test.describe("Diocese Page: Detail & Edit", () => {
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. Go to details
- const card = page
- .locator('[data-testid="diocese-card"]')
- .filter({ hasText: nameToDelete });
- await card.getByRole("button", { name: /Details/i }).click();
+ // 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 redirection to list and success toast
- await expect(page).toHaveURL(/\/setting\/diocese$/);
+ // 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
index c662b34..00c24a1 100644
--- a/apps/dash/e2e/features/setting/diocese/list.spec.ts
+++ b/apps/dash/e2e/features/setting/diocese/list.spec.ts
@@ -41,11 +41,10 @@ test.describe("Diocese Page: Browsing and Search", () => {
await expect(listPage.dioceseGrid).toContainText([cardName]);
// Verify search clear button is visible when searching
- await expect(page.getByTestId("search-clear")).toBeVisible();
+ await expect(listPage.searchClearBtn).toBeVisible();
// Clear search and verify list is restored
- await page.getByTestId("search-clear").click();
- await page.waitForTimeout(1000); // Wait for debounce
+ await listPage.clearSearch();
await expect(listPage.getVisibleDioceseCount()).resolves.toBeGreaterThan(
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/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/pages/setting/DioceseCreatePage.ts b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
index 2f06bcf..3509678 100644
--- a/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
+++ b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
@@ -28,7 +28,7 @@ export class DioceseCreatePage {
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.getByRole("heading", { level: 1 });
+ this.headerTitle = page.getByTestId("page-title");
}
/**
@@ -36,7 +36,6 @@ export class DioceseCreatePage {
*/
async goto() {
await this.page.goto("/setting/diocese/new");
- await this.page.waitForLoadState("networkidle");
}
/**
@@ -67,7 +66,6 @@ export class DioceseCreatePage {
*/
async submit() {
await this.submitBtn.click();
- await this.page.waitForLoadState("networkidle");
}
/**
@@ -75,6 +73,5 @@ export class DioceseCreatePage {
*/
async cancel() {
await this.cancelBtn.click();
- await this.page.waitForLoadState("networkidle");
}
}
diff --git a/apps/dash/e2e/pages/setting/DioceseDetailPage.ts b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
index 529bf7a..c5d46aa 100644
--- a/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
+++ b/apps/dash/e2e/pages/setting/DioceseDetailPage.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 Diocese Detail page.
@@ -34,7 +34,7 @@ export class DioceseDetailPage {
this.confirmDeleteBtn = page
.getByRole("button", { name: /Hapus/i })
.filter({ hasText: /^Hapus$/ });
- this.headerTitle = page.getByRole("heading", { level: 1 });
+ this.headerTitle = page.getByTestId("page-title");
}
/**
@@ -43,7 +43,6 @@ export class DioceseDetailPage {
*/
async goto(id: string) {
await this.page.goto(`/setting/diocese/${id}`);
- await this.page.waitForLoadState("networkidle");
}
/**
@@ -72,7 +71,6 @@ export class DioceseDetailPage {
*/
async submit() {
await this.submitBtn.click();
- await this.page.waitForLoadState("networkidle");
}
/**
@@ -80,7 +78,8 @@ export class DioceseDetailPage {
*/
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();
- await this.page.waitForLoadState("networkidle");
}
}
diff --git a/apps/dash/e2e/pages/setting/DioceseListPage.ts b/apps/dash/e2e/pages/setting/DioceseListPage.ts
index 8fafd24..bc73478 100644
--- a/apps/dash/e2e/pages/setting/DioceseListPage.ts
+++ b/apps/dash/e2e/pages/setting/DioceseListPage.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 Diocese List page.
@@ -7,6 +7,8 @@ import type { Locator, Page } from "@playwright/test";
export class DioceseListPage {
readonly page: Page;
readonly searchInput: Locator;
+ readonly searchClearBtn: Locator;
+ readonly loadingIndicator: Locator;
readonly addDioceseBtn: Locator;
readonly dioceseGrid: Locator;
readonly dioceseCards: Locator;
@@ -15,6 +17,8 @@ export class DioceseListPage {
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");
@@ -26,19 +30,40 @@ export class DioceseListPage {
*/
async goto() {
await this.page.goto("/setting/diocese");
- await this.page.waitForLoadState("networkidle");
+ await expect(this.dioceseGrid).toBeVisible();
}
/**
- * Performs a search by filling the search input.
+ * 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);
- // Wait for the debounce and URL sync to complete
- await this.page.waitForTimeout(1000);
- await this.page.waitForLoadState("networkidle");
+
+ // 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"));
}
/**
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/setting/ui/DioceseCreatePage.tsx b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
index 35cf6df..5d8897a 100644
--- a/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
+++ b/apps/dash/src/pages/setting/ui/DioceseCreatePage.tsx
@@ -40,19 +40,28 @@ export async function DioceseCreatePage() {
);
}
- const cathedralImage =
- "https://images.unsplash.com/photo-1548678912-4192d595604b?auto=format&fit=crop&q=80&w=1200";
-
return (
-
-
-
- {t("title")}
-
+
+
+
+
+
+
+ Administration
+
+
+
+ {t("title")}
- {t("description")}
+
+ {t("description")}
+
diff --git a/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx b/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx
index c4be576..b3b4c7a 100644
--- a/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx
+++ b/apps/dash/src/pages/setting/ui/DioceseDetailPage.tsx
@@ -60,20 +60,24 @@ export async function DioceseDetailPage({ id }: DioceseDetailPageProps) {
return (
- {/* Solid Premium Header */}
-
+ {/* 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/components/DioceseCard.tsx b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
index 32db91c..2474ee2 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseCard.tsx
@@ -4,10 +4,11 @@ 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 Link from "next/link";
+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 { Button, buttonVariants } from "@/shared/ui/shadcn/button";
+import { buttonVariants } from "@/shared/ui/shadcn/button";
import {
Card,
CardContent,
@@ -39,6 +40,8 @@ interface DioceseCardProps {
* - Responsive layout.
*/
export function DioceseCard({ diocese }: DioceseCardProps) {
+ const router = useRouter();
+
return (
-
+
Website
-
+
) : (
)}
-
-
- Details
-
-
+ 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
index 98bcd3b..6decc4d 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseForm.tsx
@@ -22,6 +22,7 @@ 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 {
@@ -35,7 +36,6 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/shared/ui/shadcn/alert-dialog";
-import { Button } from "@/shared/ui/shadcn/button";
interface DioceseFormProps {
/** The action to perform on submit (create or update). */
@@ -98,9 +98,10 @@ export function DioceseForm({
if (data) {
toast.success(tDetail("successToast"));
if (!initialData) {
- router.push("/setting/diocese");
+ router.push(`/setting/diocese/${data.id}`);
+ } else {
+ router.refresh();
}
- router.refresh();
}
},
});
@@ -121,8 +122,7 @@ export function DioceseForm({
}
toast.success(tDetail("deleteSuccessToast"));
- router.push("/setting/diocese");
- router.refresh();
+ router.replace("/setting/diocese");
};
return (
@@ -279,31 +279,32 @@ export function DioceseForm({
{/* Actions */}
{!isReadOnly && (
-
+ ) : (
+
+ )
+ }
>
- {isSubmitting ? (
-
- ) : (
-
- )}
{t("btnSave")}
-
+
)}
-
router.back()}
disabled={isSubmitting}
- className="h-12 px-6 rounded-2xl font-medium gap-2 text-muted-foreground hover:text-foreground hover:bg-muted"
+ icon={ }
>
-
{t("btnCancel")}
-
+
{!isReadOnly && onDelete && (
@@ -311,17 +312,16 @@ export function DioceseForm({
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
-
setIsDeleteDialogOpen(true)}
disabled={isSubmitting || isDeleting}
data-testid="delete-diocese-btn"
- className="h-12 px-6 rounded-2xl font-bold gap-2 bg-destructive/10 text-destructive border border-destructive/20 hover:bg-destructive hover:text-destructive-foreground transition-all shadow-none hover:shadow-lg hover:shadow-destructive/20"
+ icon={ }
>
-
{tDetail("btnDelete")}
-
+
diff --git a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
index 43e713c..63bd00e 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
@@ -5,8 +5,8 @@ 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 { PremiumSearch } from "@/shared/ui/components/PremiumSearch";
-import { Button } from "@/shared/ui/shadcn/button";
import { DioceseList } from "./DioceseList";
/**
@@ -79,16 +79,14 @@ export function DioceseListContent({
{canManage && (
-
router.push("/setting/diocese/new")}
- className="h-12 px-6 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg shadow-primary/20 transition-all duration-300 gap-2 font-bold group"
+ variant="primary"
+ icon={ }
>
-
{t("addDiocese")}
-
+
)}
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 (
-
{isLoading || isPending ? (
-
+
) : (
)}
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index 3bd33bf..42a5cb6 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -213,6 +213,13 @@ export function createAuth(
generateId: () => v7(),
},
},
+
+ session: {
+ cookieCache: {
+ enabled: true,
+ maxAge: 5 * 60,
+ },
+ },
});
}
diff --git a/skills-lock.json b/skills-lock.json
index 5b38a2f..67e4737 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -91,6 +91,16 @@
"sourceType": "github",
"computedHash": "79a5a85b43d10e9fe37582b3506b051a33a991817b938f349099baa5ddba21aa"
},
+ "playwright-best-practices": {
+ "source": "currents-dev/playwright-best-practices-skill",
+ "sourceType": "github",
+ "computedHash": "320eaf861e3a0a923bfaad0726cb311a3fe30093378f8d26c89b9c5ae5c42b35"
+ },
+ "playwright-cli": {
+ "source": "microsoft/playwright-cli",
+ "sourceType": "github",
+ "computedHash": "f75195249064c00f8cc69f8df10b947ea09196a78b784c1356dd032e1bf67a36"
+ },
"react:components": {
"source": "google-labs-code/stitch-skills",
"sourceType": "github",
From a9a8223cda923a56502343faf48f84ad420bff6f Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 02:56:15 +0800
Subject: [PATCH 07/13] feat(dash): add PremiumHero component and integrate it
into DioceseList
- Implement `PremiumHero` shared component with high-fidelity glassmorphism and animations.
- Integrate `PremiumHero` into `DioceseListContent.tsx` replacing the old header.
- Update `DemoPage.tsx` to showcase the new `PremiumHero` variants.
- Export `PremiumHero` from the shared UI package.
- Add `premiumHero` test id to the list page E2E page object.
---
.../dash/e2e/pages/setting/DioceseListPage.ts | 2 +
apps/dash/src/pages/demo/ui/DemoPage.tsx | 98 ++++------
.../ui/components/DioceseListContent.tsx | 54 +++---
.../src/shared/ui/components/PremiumHero.tsx | 176 ++++++++++++++++++
apps/dash/src/shared/ui/components/index.ts | 1 +
5 files changed, 237 insertions(+), 94 deletions(-)
create mode 100644 apps/dash/src/shared/ui/components/PremiumHero.tsx
diff --git a/apps/dash/e2e/pages/setting/DioceseListPage.ts b/apps/dash/e2e/pages/setting/DioceseListPage.ts
index bc73478..648e64f 100644
--- a/apps/dash/e2e/pages/setting/DioceseListPage.ts
+++ b/apps/dash/e2e/pages/setting/DioceseListPage.ts
@@ -13,6 +13,7 @@ export class DioceseListPage {
readonly dioceseGrid: Locator;
readonly dioceseCards: Locator;
readonly emptyState: Locator;
+ readonly premiumHero: Locator;
constructor(page: Page) {
this.page = page;
@@ -23,6 +24,7 @@ export class DioceseListPage {
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");
}
/**
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/setting/ui/components/DioceseListContent.tsx b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
index 63bd00e..bdacdb5 100644
--- a/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/DioceseListContent.tsx
@@ -5,8 +5,11 @@ 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 { PremiumSearch } from "@/shared/ui/components/PremiumSearch";
+import {
+ PremiumAction,
+ PremiumHero,
+ PremiumSearch,
+} from "@/shared/ui/components";
import { DioceseList } from "./DioceseList";
/**
@@ -63,32 +66,27 @@ export function DioceseListContent({
className="space-y-8 animate-in fade-in duration-700"
data-testid="diocese-list-page"
>
- {/* Header Section */}
-
-
-
-
- Administration
-
-
- {t("title")}
-
-
- {t("description")}
-
-
-
- {canManage && (
-
router.push("/setting/diocese/new")}
- variant="primary"
- icon={ }
- >
- {t("addDiocese")}
-
- )}
-
+ {/* 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 */}
diff --git a/apps/dash/src/shared/ui/components/PremiumHero.tsx b/apps/dash/src/shared/ui/components/PremiumHero.tsx
new file mode 100644
index 0000000..044050d
--- /dev/null
+++ b/apps/dash/src/shared/ui/components/PremiumHero.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { motion } from "motion/react";
+import Image from "next/image";
+import type * as React from "react";
+import { cn } from "@/shared/ui/common/utils";
+
+/**
+ * Props for the PremiumHero component.
+ */
+export interface PremiumHeroProps {
+ /**
+ * The background image URL.
+ */
+ image: string;
+ /**
+ * The main headline of the hero.
+ */
+ title: React.ReactNode;
+ /**
+ * Supporting text or description.
+ */
+ description?: React.ReactNode;
+ /**
+ * Optional logo or icon to display in the header section.
+ */
+ logo?: React.ReactNode;
+ /**
+ * Optional organization name or subtitle.
+ */
+ orgName?: React.ReactNode;
+ /**
+ * Optional list of organizations or tags.
+ */
+ tags?: string[];
+ /**
+ * Action components, typically PremiumAction buttons.
+ */
+ actions?: React.ReactNode;
+ /**
+ * Additional CSS classes for the container.
+ */
+ className?: string;
+}
+
+/**
+ * PremiumHero is a high-fidelity hero section with glassmorphism,
+ * rounded corners, and sophisticated entry animations.
+ */
+export function PremiumHero({
+ image,
+ title,
+ description,
+ logo,
+ orgName,
+ tags,
+ actions,
+ className,
+}: PremiumHeroProps) {
+ 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/index.ts b/apps/dash/src/shared/ui/components/index.ts
index 436515b..4ee7e14 100644
--- a/apps/dash/src/shared/ui/components/index.ts
+++ b/apps/dash/src/shared/ui/components/index.ts
@@ -2,6 +2,7 @@ export * from "./DomusCard";
export * from "./LegalDialog";
export * from "./PremiumAction";
export * from "./PremiumFooter";
+export * from "./PremiumHero";
export * from "./PremiumSearch";
export * from "./PrivacyView";
export * from "./Providers";
From b22c0396549c3113d462fba59c6e1d82b1a2c721 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 03:36:02 +0800
Subject: [PATCH 08/13] test(dash): fix parish list e2e with auth and data
seeding (#140)
- implement mandatory authentication in parish.spec.ts
- create iHaveParish and iHaveVicariate e2e helpers
- expose missing parish and vicariate services in shared core
- add data-testid to add-parish-btn for robust locators
- update POM to use stable test identifiers
---
apps/dash/app/(dash)/setting/parish/page.tsx | 15 ++
apps/dash/e2e/features/setting/parish.spec.ts | 62 ++++++
apps/dash/e2e/helper/index.ts | 4 +
apps/dash/e2e/helper/parish.ts | 58 ++++++
apps/dash/e2e/helper/vicariate.ts | 55 +++++
apps/dash/e2e/pages/setting/ParishListPage.ts | 95 +++++++++
apps/dash/src/pages/setting/actions/parish.ts | 195 ++++++++++++++++++
.../src/pages/setting/ui/ParishListPage.tsx | 18 ++
.../setting/ui/components/ParishCard.tsx | 69 +++++++
.../setting/ui/components/ParishList.tsx | 87 ++++++++
.../ui/components/ParishListContent.tsx | 100 +++++++++
apps/dash/src/shared/core/index.ts | 2 +
apps/dash/src/shared/core/service.ts | 4 +
apps/dash/src/shared/i18n/messages/en.json | 50 +++++
apps/dash/src/shared/i18n/messages/id.json | 50 +++++
packages/core/src/contract/parish.ts | 6 +-
packages/core/src/service/parish.ts | 11 +-
packages/db/src/repository/parish.ts | 25 ++-
18 files changed, 895 insertions(+), 11 deletions(-)
create mode 100644 apps/dash/app/(dash)/setting/parish/page.tsx
create mode 100644 apps/dash/e2e/features/setting/parish.spec.ts
create mode 100644 apps/dash/e2e/helper/parish.ts
create mode 100644 apps/dash/e2e/helper/vicariate.ts
create mode 100644 apps/dash/e2e/pages/setting/ParishListPage.ts
create mode 100644 apps/dash/src/pages/setting/actions/parish.ts
create mode 100644 apps/dash/src/pages/setting/ui/ParishListPage.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishCard.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishList.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishListContent.tsx
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..4e8bfe7
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/parish/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import { ParishListPage } from "@/pages/setting/ui/ParishListPage";
+
+export const metadata: Metadata = {
+ title: "Daftar Paroki | Domus",
+ description: "Kelola data paroki dalam sistem Domus.",
+};
+
+interface PageProps {
+ searchParams: Promise<{ q?: string }>;
+}
+
+export default function Page({ searchParams }: PageProps) {
+ return ;
+}
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/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..05f6a59
--- /dev/null
+++ b/apps/dash/e2e/helper/parish.ts
@@ -0,0 +1,58 @@
+import type { AuthContext, CreateParish } from "@domus/core";
+import service from "@/shared/core";
+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
+ const [parishes] = await service.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 service.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/vicariate.ts b/apps/dash/e2e/helper/vicariate.ts
new file mode 100644
index 0000000..f006911
--- /dev/null
+++ b/apps/dash/e2e/helper/vicariate.ts
@@ -0,0 +1,55 @@
+import type { AuthContext, CreateVicariate } from "@domus/core";
+import service from "@/shared/core";
+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
+ const [vicariates] = await service.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 service.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/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/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/ui/ParishListPage.tsx b/apps/dash/src/pages/setting/ui/ParishListPage.tsx
new file mode 100644
index 0000000..f0693d9
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/ParishListPage.tsx
@@ -0,0 +1,18 @@
+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);
+
+ return ;
+}
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..6a22ee5
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/ParishCard.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import type { Parish } from "@domus/core";
+import { Building2, Globe, Mail, Phone } from "lucide-react";
+import Link from "next/link";
+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.email && (
+
+
+ {parish.email}
+
+ )}
+ {parish.website && (
+
+
+ {parish.website}
+
+ )}
+
+
+
+ {parish.vicariateId ? "Vicariate Scoped" : "General Parish"}
+
+
+
+
+
+
+ );
+}
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..82e2aa0
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx
@@ -0,0 +1,100 @@
+"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>;
+}
+
+/**
+ * Client-side layout and state management for the Parish list page.
+ * Syncs search state with URL and handles presentation.
+ */
+export function ParishListContent({ promise }: 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={
+
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/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 2ca5640..bf95045 100644
--- a/apps/dash/src/shared/i18n/messages/en.json
+++ b/apps/dash/src/shared/i18n/messages/en.json
@@ -105,6 +105,56 @@
"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...",
+ "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.",
diff --git a/apps/dash/src/shared/i18n/messages/id.json b/apps/dash/src/shared/i18n/messages/id.json
index 4904785..a9fa8ee 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -117,6 +117,56 @@
"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...",
+ "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.",
diff --git a/packages/core/src/contract/parish.ts b/packages/core/src/contract/parish.ts
index d6fb2fe..1f94b9b 100644
--- a/packages/core/src/contract/parish.ts
+++ b/packages/core/src/contract/parish.ts
@@ -10,9 +10,11 @@ export interface IParishRepository {
findById(id: string): Promise;
/**
- * Finds all Parishes.
+ * Finds all Parishes, optionally filtered by a search query.
+ *
+ * @param q - Optional search query.
*/
- findAll(): Promise;
+ findAll(q?: string): Promise;
/**
* Finds all Parishes in a specific Vicariate.
diff --git a/packages/core/src/service/parish.ts b/packages/core/src/service/parish.ts
index d28ac6e..ca46aeb 100644
--- a/packages/core/src/service/parish.ts
+++ b/packages/core/src/service/parish.ts
@@ -42,11 +42,14 @@ export class ParishService {
}
/**
- * Finds all parishes.
+ * Finds all Parishes, optionally filtered by a search query.
* Requires the user to be an approved member.
+ *
+ * @param ctx - The user's authentication context.
+ * @param q - Optional search query.
*/
- async findAll(ctx: AuthContext): Promise> {
- this.logger.info('ParishService.findAll', { userId: ctx.userId });
+ async findAll(ctx: AuthContext, q?: string): Promise> {
+ this.logger.info('ParishService.findAll', { userId: ctx.userId, q });
if (ctx.accountStatus !== AccountStatus.Approved) {
this.logger.warn('ParishService.findAll: unauthorized', {
@@ -55,7 +58,7 @@ export class ParishService {
return fail(new ForbiddenError('Only approved users can view parishes.'));
}
- const parishes = await this.repo.findAll();
+ const parishes = await this.repo.findAll(q);
return ok(parishes);
}
diff --git a/packages/db/src/repository/parish.ts b/packages/db/src/repository/parish.ts
index 7245e0b..832f98f 100644
--- a/packages/db/src/repository/parish.ts
+++ b/packages/db/src/repository/parish.ts
@@ -6,7 +6,7 @@ import {
ParishEntity,
type UpdateParish,
} from '@domus/core';
-import { and, eq, isNull } from 'drizzle-orm';
+import { and, eq, ilike, isNull, or } from 'drizzle-orm';
import type { DrizzleClient } from '../index';
import { parishes } from '../schema/parishes';
@@ -38,14 +38,29 @@ export class ParishRepository implements IParishRepository {
}
/**
- * Finds all parishes.
+ * Finds all parishes, optionally filtered by a search query.
+ *
+ * @param q - Optional search query.
*/
- async findAll(): Promise {
- this.logger.info('ParishRepository.findAll');
+ async findAll(q?: string): Promise {
+ this.logger.info('ParishRepository.findAll', { q });
+
+ const conditions = [isNull(parishes.deletedAt)];
+
+ if (q) {
+ const searchCondition = or(
+ ilike(parishes.name, `%${q}%`),
+ ilike(parishes.address, `%${q}%`),
+ );
+ if (searchCondition) {
+ conditions.push(searchCondition);
+ }
+ }
+
const rows = await this.db
.select()
.from(parishes)
- .where(isNull(parishes.deletedAt));
+ .where(and(...conditions));
return rows.map((row) => ParishEntity.parse(row));
}
From 528152ca45d3b1b4fe11b960e2952cd09db761f6 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 04:39:28 +0800
Subject: [PATCH 09/13] feat(dash): implement parish creation with chained
selection & UI
- Add Parish creation form with Diocese -> Vicariate chained selection
- Implement random cathedral images for parish cards
- Fix E2E infrastructure crash by using lazy core service initialization
- Add noValidate to ParishForm to ensure custom Zod errors are visible in tests
- Update E2E helpers and spec with Indonesian localization support
- Resolve React key collision in ValidationErrors component
Ref #140
---
app/(dash)/setting/parish/new/page.tsx | 1 +
.../app/(dash)/setting/parish/[id]/page.tsx | 8 +
.../app/(dash)/setting/parish/new/page.tsx | 8 +
apps/dash/app/(dash)/setting/parish/page.tsx | 12 +-
.../features/setting/parish/create.spec.ts | 72 +++
apps/dash/e2e/helper/diocese.ts | 10 +-
apps/dash/e2e/helper/parish.ts | 10 +-
apps/dash/e2e/helper/user.ts | 13 +-
apps/dash/e2e/helper/vicariate.ts | 10 +-
.../e2e/pages/setting/ParishCreatePage.ts | 84 ++++
apps/dash/src/pages/setting/actions/index.ts | 3 +
.../src/pages/setting/actions/vicariate.ts | 45 ++
.../src/pages/setting/ui/ParishCreatePage.tsx | 33 ++
.../src/pages/setting/ui/ParishDetailPage.tsx | 27 ++
.../setting/ui/components/ParishCard.tsx | 5 +-
.../ui/components/ParishCreateContent.tsx | 44 ++
.../ui/components/ParishDetailContent.tsx | 65 +++
.../setting/ui/components/ParishForm.tsx | 431 ++++++++++++++++++
apps/dash/src/shared/config/actions.ts | 8 +
apps/dash/src/shared/config/images.ts | 28 ++
apps/dash/src/shared/i18n/messages/en.json | 7 +
apps/dash/src/shared/i18n/messages/id.json | 7 +
.../shared/ui/components/ValidationErrors.tsx | 11 +-
.../dash/src/shared/ui/fields/SelectField.tsx | 4 +
24 files changed, 917 insertions(+), 29 deletions(-)
create mode 100644 app/(dash)/setting/parish/new/page.tsx
create mode 100644 apps/dash/app/(dash)/setting/parish/[id]/page.tsx
create mode 100644 apps/dash/app/(dash)/setting/parish/new/page.tsx
create mode 100644 apps/dash/e2e/features/setting/parish/create.spec.ts
create mode 100644 apps/dash/e2e/pages/setting/ParishCreatePage.ts
create mode 100644 apps/dash/src/pages/setting/actions/index.ts
create mode 100644 apps/dash/src/pages/setting/actions/vicariate.ts
create mode 100644 apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
create mode 100644 apps/dash/src/pages/setting/ui/ParishDetailPage.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishCreateContent.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
create mode 100644 apps/dash/src/pages/setting/ui/components/ParishForm.tsx
create mode 100644 apps/dash/src/shared/config/images.ts
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/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
index 4e8bfe7..29c4f92 100644
--- a/apps/dash/app/(dash)/setting/parish/page.tsx
+++ b/apps/dash/app/(dash)/setting/parish/page.tsx
@@ -2,14 +2,8 @@ import type { Metadata } from "next";
import { ParishListPage } from "@/pages/setting/ui/ParishListPage";
export const metadata: Metadata = {
- title: "Daftar Paroki | Domus",
- description: "Kelola data paroki dalam sistem Domus.",
+ title: "Parish List | Domus",
+ description: "Manage parishes in the Domus system.",
};
-interface PageProps {
- searchParams: Promise<{ q?: string }>;
-}
-
-export default function Page({ searchParams }: PageProps) {
- return ;
-}
+export default ParishListPage;
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/helper/diocese.ts b/apps/dash/e2e/helper/diocese.ts
index 64bf528..d91cf61 100644
--- a/apps/dash/e2e/helper/diocese.ts
+++ b/apps/dash/e2e/helper/diocese.ts
@@ -1,5 +1,5 @@
import type { AuthContext, CreateDiocese } from "@domus/core";
-import service from "@/shared/core";
+import * as core from "../../src/shared/core/service";
/**
* Payload for creating a diocese in tests.
@@ -23,7 +23,11 @@ export async function iHaveDiocese(payload: DiocesePayload) {
};
// 1. Try Find by Name
- const [dioceses] = await service.diocese.findAll(
+ if (!core.diocese) {
+ throw new Error("Diocese service is undefined in iHaveDiocese helper");
+ }
+
+ const [dioceses] = await core.diocese.findAll(
{
userId: "system",
roles: ["super-admin"],
@@ -36,7 +40,7 @@ export async function iHaveDiocese(payload: DiocesePayload) {
// 2. Create if not exists
if (!diocese) {
- const [created, error] = await service.diocese.create(
+ const [created, error] = await core.diocese.create(
withDefaults,
{
userId: "system",
diff --git a/apps/dash/e2e/helper/parish.ts b/apps/dash/e2e/helper/parish.ts
index 05f6a59..bcec042 100644
--- a/apps/dash/e2e/helper/parish.ts
+++ b/apps/dash/e2e/helper/parish.ts
@@ -1,5 +1,5 @@
import type { AuthContext, CreateParish } from "@domus/core";
-import service from "@/shared/core";
+import * as core from "../../src/shared/core/service";
import { iHaveVicariate } from "./vicariate";
/**
@@ -28,7 +28,11 @@ export async function iHaveParish(payload: ParishPayload) {
};
// 1. Try Find by Name
- const [parishes] = await service.parish.findAll(
+ if (!core.parish) {
+ throw new Error("Parish service is undefined in iHaveParish helper");
+ }
+
+ const [parishes] = await core.parish.findAll(
{
userId: "system",
roles: ["super-admin"],
@@ -41,7 +45,7 @@ export async function iHaveParish(payload: ParishPayload) {
// 2. Create if not exists
if (!parish) {
- const [created, error] = await service.parish.create(
+ const [created, error] = await core.parish.create(
withDefaults,
{
userId: "system",
diff --git a/apps/dash/e2e/helper/user.ts b/apps/dash/e2e/helper/user.ts
index b992cb5..956f804 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,13 +29,16 @@ 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;
}
@@ -55,14 +58,14 @@ export async function iHaveUser(payload: UserPayload) {
);
}
- const [d, e] = await service.user.findById(res.user.id);
+ const [d, e] = await core.user.findById(res.user.id);
if (e) throw e;
user = d;
}
// 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
index f006911..5549f5d 100644
--- a/apps/dash/e2e/helper/vicariate.ts
+++ b/apps/dash/e2e/helper/vicariate.ts
@@ -1,5 +1,5 @@
import type { AuthContext, CreateVicariate } from "@domus/core";
-import service from "@/shared/core";
+import * as core from "../../src/shared/core/service";
import { iHaveDiocese } from "./diocese";
/**
@@ -26,7 +26,11 @@ export async function iHaveVicariate(payload: VicariatePayload) {
};
// 2. Try Find by Name
- const [vicariates] = await service.vicariate.findAll(
+ if (!core.vicariate) {
+ throw new Error("Vicariate service is undefined in iHaveVicariate helper");
+ }
+
+ const [vicariates] = await core.vicariate.findAll(
{
userId: "system",
roles: ["super-admin"],
@@ -38,7 +42,7 @@ export async function iHaveVicariate(payload: VicariatePayload) {
// 3. Create if not exists
if (!vicariate) {
- const [created, error] = await service.vicariate.create(
+ const [created, error] = await core.vicariate.create(
withDefaults,
{
userId: "system",
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/src/pages/setting/actions/index.ts b/apps/dash/src/pages/setting/actions/index.ts
new file mode 100644
index 0000000..1654196
--- /dev/null
+++ b/apps/dash/src/pages/setting/actions/index.ts
@@ -0,0 +1,3 @@
+export * from "./diocese";
+export * from "./parish";
+export * from "./vicariate";
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/ParishCreatePage.tsx b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
new file mode 100644
index 0000000..6578efc
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+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() {
+ // 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..64ba92e
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx
@@ -0,0 +1,27 @@
+import { notFound } from "next/navigation";
+import { getParishAction, listDiocesesAction } from "../actions";
+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]] = await Promise.all([
+ getParishAction(id),
+ listDiocesesAction(),
+ ]);
+
+ if (error || !parish) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/apps/dash/src/pages/setting/ui/components/ParishCard.tsx b/apps/dash/src/pages/setting/ui/components/ParishCard.tsx
index 6a22ee5..b5ab7ae 100644
--- a/apps/dash/src/pages/setting/ui/components/ParishCard.tsx
+++ b/apps/dash/src/pages/setting/ui/components/ParishCard.tsx
@@ -3,6 +3,7 @@
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,
@@ -28,9 +29,7 @@ export function ParishCard({ parish }: ParishCardProps) {
className="h-full flex flex-col"
>
{parish.name}
{parish.address}
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..5bab28f
--- /dev/null
+++ b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import type { Diocese, Parish } from "@domus/core";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { deleteParishAction, updateParishAction } from "../../actions/parish";
+import { ParishForm } from "./ParishForm";
+
+interface ParishDetailContentProps {
+ parish: Parish & { dioceseId?: string };
+ dioceses: Diocese[];
+}
+
+/**
+ * Client Component: Parish Detail Page Content.
+ * Wraps the ParishForm in edit mode and handles breadcrumbs.
+ */
+export function ParishDetailContent({
+ parish,
+ dioceses,
+}: 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}
+ />
+
+ );
+}
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 (
+
+ );
+
+ 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/shared/config/actions.ts b/apps/dash/src/shared/config/actions.ts
index 62b5788..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,
@@ -85,4 +86,11 @@ export const DASHBOARD_ACTIONS: LayananItem[] = [
hoverTextColor: "hover:text-[#0288d1]",
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/i18n/messages/en.json b/apps/dash/src/shared/i18n/messages/en.json
index bf95045..a38494d 100644
--- a/apps/dash/src/shared/i18n/messages/en.json
+++ b/apps/dash/src/shared/i18n/messages/en.json
@@ -138,6 +138,8 @@
"logoPlaceholder": "https://... (PNG/JPG)",
"descLabel": "Description",
"descPlaceholder": "Short profile of the parish...",
+ "dioceseLabel": "Diocese",
+ "diocesePlaceholder": "Select Diocese",
"vicariateLabel": "Vicariate",
"vicariatePlaceholder": "Select Vicariate",
"btnSave": "Save",
@@ -474,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 a9fa8ee..90c723a 100644
--- a/apps/dash/src/shared/i18n/messages/id.json
+++ b/apps/dash/src/shared/i18n/messages/id.json
@@ -150,6 +150,8 @@
"logoPlaceholder": "https://... (PNG/JPG)",
"descLabel": "Deskripsi",
"descPlaceholder": "Profil singkat paroki...",
+ "dioceseLabel": "Keuskupan",
+ "diocesePlaceholder": "Pilih Keuskupan",
"vicariateLabel": "Vikariat",
"vicariatePlaceholder": "Pilih Vikariat",
"btnSave": "Simpan",
@@ -495,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/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({
Date: Mon, 13 Apr 2026 05:16:53 +0800
Subject: [PATCH 10/13] feat(dash): improve org join E2E reliability and
implement mock storage
- Add data-testid to JoinForm components (birth-date-trigger, id-card-input, etc)
- Improve OrgJoinPage POM with precise date selection and element waiting
- Implement E2E mock mode for ID card upload/delete via NEXT_PUBLIC_E2E_MOCK
- Configure playwright global setup for mock asset management
- Update turbo.json to include NEXT_PUBLIC_E2E_MOCK environment variable
- Add .gitignore for E2E assets
---
apps/dash/e2e/assets/.gitignore | 1 +
apps/dash/e2e/features/org/join.spec.ts | 49 ++++++-------------
apps/dash/e2e/global-setup.ts | 44 +++++++++++++++++
apps/dash/e2e/pages/org/OrgJoinPage.ts | 39 ++++++++++-----
apps/dash/playwright.config.ts | 3 +-
apps/dash/src/pages/org/actions/join.ts | 18 +++++++
.../src/pages/org/ui/components/JoinForm.tsx | 9 +++-
turbo.json | 3 +-
8 files changed, 117 insertions(+), 49 deletions(-)
create mode 100644 apps/dash/e2e/assets/.gitignore
create mode 100644 apps/dash/e2e/global-setup.ts
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/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/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/playwright.config.ts b/apps/dash/playwright.config.ts
index 0e999fd..e6ff2cd 100644
--- a/apps/dash/playwright.config.ts
+++ b/apps/dash/playwright.config.ts
@@ -11,6 +11,7 @@ dotenv.config({
*/
export default defineConfig({
testDir: "./e2e",
+ globalSetup: "./e2e/global-setup.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 +41,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/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({
) : (
-
+
Date: Mon, 13 Apr 2026 05:37:02 +0800
Subject: [PATCH 11/13] feat(dash): implement post-test database truncation
- Added truncateAllTables utility in @domus/db with production protection.
- Registered globalTeardown in Playwright config to automate database cleanup.
- Configured teardown to skip regions tables to preserve master data.
---
apps/dash/e2e/global-teardown.ts | 25 +++++++++++
apps/dash/playwright.config.ts | 1 +
packages/db/src/index.ts | 1 +
packages/db/src/reset.ts | 72 ++++++++++++++++++++++++++++++++
4 files changed, 99 insertions(+)
create mode 100644 apps/dash/e2e/global-teardown.ts
create mode 100644 packages/db/src/reset.ts
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/playwright.config.ts b/apps/dash/playwright.config.ts
index e6ff2cd..fa10ac7 100644
--- a/apps/dash/playwright.config.ts
+++ b/apps/dash/playwright.config.ts
@@ -12,6 +12,7 @@ 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. */
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index d8cfd16..a80ce1e 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -35,4 +35,5 @@ export type DrizzleClient =
| NodePgDatabase;
export * from './repository';
+export * from './reset';
export * from './schema';
diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts
new file mode 100644
index 0000000..2526afd
--- /dev/null
+++ b/packages/db/src/reset.ts
@@ -0,0 +1,72 @@
+import c from '@domus/config';
+import { sql } from 'drizzle-orm';
+import { db } from './index';
+
+/**
+ * List of tables that should NOT be truncated.
+ * These are master data or internal metadata tables.
+ */
+const EXCLUDED_TABLES = [
+ 'provinces',
+ 'regencies',
+ 'districts',
+ 'villages',
+ '__drizzle_migrations',
+ 'drizzle_migrations',
+ 'drizzle_migrations_lock',
+];
+
+/**
+ * Truncates all tables in the database except for excluded ones.
+ *
+ * @param force - If true, bypasses production environment protection. Defaults to false.
+ * @throws Error if environment is production and force is not true.
+ */
+export async function truncateAllTables(force = false): Promise {
+ // Production Protection
+ const isProduction = c.app.env === 'production';
+ const isE2EMock = process.env.NEXT_PUBLIC_E2E_MOCK === 'true';
+
+ if (isProduction && !force && !isE2EMock) {
+ console.error(
+ '⚠️ [ABORT] truncateAllTables called in PRODUCTION environment without force flag!',
+ );
+ throw new Error('Database truncation blocked in production environment.');
+ }
+
+ console.log('🧼 Truncating database tables...');
+
+ try {
+ // 1. Get all table names in public schema
+ const tables = await db.execute(sql`
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_type = 'BASE TABLE';
+ `);
+
+ // 2. Filter out excluded tables
+ const tablesToTruncate = tables.rows
+ .map((row) => row.table_name as string)
+ .filter((name) => !EXCLUDED_TABLES.includes(name));
+
+ if (tablesToTruncate.length === 0) {
+ console.log('ℹ️ No tables to truncate.');
+ return;
+ }
+
+ // 3. Construct and execute TRUNCATE command
+ // We use CASCADE to handle foreign key dependencies
+ const truncateQuery = sql.raw(
+ `TRUNCATE TABLE ${tablesToTruncate.map((name) => `"${name}"`).join(', ')} CASCADE;`,
+ );
+
+ await db.execute(truncateQuery);
+
+ console.log(`✅ Successfully truncated ${tablesToTruncate.length} tables.`);
+ console.log('Skipped:', EXCLUDED_TABLES.join(', '));
+ } catch (error) {
+ console.error('❌ Failed to truncate tables:', error);
+ throw error;
+ }
+}
From d213a6f8a3af49360eebaacecd51a19038881965 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 11:47:31 +0800
Subject: [PATCH 12/13] feat(dash): implement parish detail page with e2e tests
- Add ParishDetailHeader and ParishDetailContent components
- Update parish detail page to use logic-less components
- Implement ParishDetailPage Page Object for E2E testing
- Add comprehensive E2E tests for read, update, and delete flows
- Fix race condition in user creation E2E helper (iHaveUser)
- Refine validation error locators for Indonesian locale compatibility
Ref #140
---
.../features/setting/parish/detail.spec.ts | 78 +++++++++++++
apps/dash/e2e/helper/user.ts | 44 ++++---
.../e2e/pages/setting/ParishDetailPage.ts | 108 ++++++++++++++++++
.../ui/components/ParishDetailContent.tsx | 45 +++++---
4 files changed, 244 insertions(+), 31 deletions(-)
create mode 100644 apps/dash/e2e/features/setting/parish/detail.spec.ts
create mode 100644 apps/dash/e2e/pages/setting/ParishDetailPage.ts
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/helper/user.ts b/apps/dash/e2e/helper/user.ts
index 956f804..3602ffe 100644
--- a/apps/dash/e2e/helper/user.ts
+++ b/apps/dash/e2e/helper/user.ts
@@ -44,23 +44,37 @@ export async function iHaveUser(payload: UserPayload) {
// 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 core.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)
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/src/pages/setting/ui/components/ParishDetailContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
index 5bab28f..4781aaf 100644
--- a/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
@@ -3,6 +3,12 @@
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";
@@ -13,7 +19,7 @@ interface ParishDetailContentProps {
/**
* Client Component: Parish Detail Page Content.
- * Wraps the ParishForm in edit mode and handles breadcrumbs.
+ * Features a premium glassmorphic UI container for the edit form.
*/
export function ParishDetailContent({
parish,
@@ -28,7 +34,7 @@ export function ParishDetailContent({
return (
{/* Visual Breadcrumb */}
-
+
Setting
@@ -45,21 +51,28 @@ export function ParishDetailContent({
-
-
- {t("title")}
-
-
- {t("description")}
-
-
+
+
+
+
+ {t("title")}
+
+
+ {t("description")}
+
+
- updateParishAction(parish.id, data)}
- dioceses={dioceses}
- initialData={parish}
- onDelete={handleDelete}
- />
+ updateParishAction(parish.id, data)}
+ dioceses={dioceses}
+ initialData={parish}
+ onDelete={handleDelete}
+ />
+
+
);
}
From da55717832a2b5f307407ada8a27f34272334272 Mon Sep 17 00:00:00 2001
From: Anthonius Munthi
Date: Mon, 13 Apr 2026 12:06:42 +0800
Subject: [PATCH 13/13] feat(dash): implement rbac enforcement for parish
management
- add e2e rbac test suite for parish management
- implement role-based access control gates on parish creation and detail pages
- conditionally show/hide management actions in parish list and detail views
- enforce read-only state for approved parishioners without admin roles
Ref #140
---
.../e2e/features/setting/parish/rbac.spec.ts | 82 +++++++++++++++++++
apps/dash/src/pages/setting/actions/index.ts | 3 -
.../src/pages/setting/ui/ParishCreatePage.tsx | 25 ++++++
.../src/pages/setting/ui/ParishDetailPage.tsx | 24 +++++-
.../src/pages/setting/ui/ParishListPage.tsx | 10 ++-
.../ui/components/ParishDetailContent.tsx | 3 +
.../ui/components/ParishListContent.tsx | 25 ++++--
7 files changed, 156 insertions(+), 16 deletions(-)
create mode 100644 apps/dash/e2e/features/setting/parish/rbac.spec.ts
delete mode 100644 apps/dash/src/pages/setting/actions/index.ts
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/src/pages/setting/actions/index.ts b/apps/dash/src/pages/setting/actions/index.ts
deleted file mode 100644
index 1654196..0000000
--- a/apps/dash/src/pages/setting/actions/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./diocese";
-export * from "./parish";
-export * from "./vicariate";
diff --git a/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
index 6578efc..067c082 100644
--- a/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
+++ b/apps/dash/src/pages/setting/ui/ParishCreatePage.tsx
@@ -1,4 +1,7 @@
+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";
@@ -7,6 +10,28 @@ import { ParishCreateContent } from "./components/ParishCreateContent";
* 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();
diff --git a/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx b/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx
index 64ba92e..53e2bfe 100644
--- a/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx
+++ b/apps/dash/src/pages/setting/ui/ParishDetailPage.tsx
@@ -1,5 +1,8 @@
+import { UserRole } from "@domus/core";
import { notFound } from "next/navigation";
-import { getParishAction, listDiocesesAction } from "../actions";
+import { getAuthContext } from "@/shared/auth/server";
+import { listDiocesesAction } from "../actions/diocese";
+import { getParishAction } from "../actions/parish";
import { ParishDetailContent } from "./components/ParishDetailContent";
/**
@@ -14,14 +17,29 @@ export default async function ParishDetailPage({
const { id } = await params;
// Parallel fetch for better performance
- const [[parish, error], [dioceses]] = await Promise.all([
+ const [[parish, error], [dioceses], [auth]] = await Promise.all([
getParishAction(id),
listDiocesesAction(),
+ getAuthContext(),
]);
if (error || !parish) {
notFound();
}
- return ;
+ 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
index f0693d9..2972758 100644
--- a/apps/dash/src/pages/setting/ui/ParishListPage.tsx
+++ b/apps/dash/src/pages/setting/ui/ParishListPage.tsx
@@ -1,3 +1,5 @@
+import { UserRole } from "@domus/core";
+import { getAuthContext } from "@/shared/auth/server";
import { listParishesAction } from "../actions/parish";
import { ParishListContent } from "./components/ParishListContent";
@@ -13,6 +15,12 @@ interface ParishListPageProps {
export async function ParishListPage({ searchParams }: ParishListPageProps) {
const { q } = await searchParams;
const promise = listParishesAction(q);
+ const [auth] = await getAuthContext();
- return ;
+ 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/ParishDetailContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
index 4781aaf..c12066f 100644
--- a/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/ParishDetailContent.tsx
@@ -15,6 +15,7 @@ import { ParishForm } from "./ParishForm";
interface ParishDetailContentProps {
parish: Parish & { dioceseId?: string };
dioceses: Diocese[];
+ isReadOnly?: boolean;
}
/**
@@ -24,6 +25,7 @@ interface ParishDetailContentProps {
export function ParishDetailContent({
parish,
dioceses,
+ isReadOnly = false,
}: ParishDetailContentProps) {
const t = useTranslations("ParishDetailPage");
@@ -70,6 +72,7 @@ export function ParishDetailContent({
dioceses={dioceses}
initialData={parish}
onDelete={handleDelete}
+ isReadOnly={isReadOnly}
/>
diff --git a/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx b/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx
index 82e2aa0..046ab37 100644
--- a/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx
+++ b/apps/dash/src/pages/setting/ui/components/ParishListContent.tsx
@@ -13,13 +13,18 @@ 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 }: ParishListContentProps) {
+export function ParishListContent({
+ promise,
+ canManage = false,
+}: ParishListContentProps) {
const t = useTranslations("ParishPage");
const router = useRouter();
const pathname = usePathname();
@@ -60,14 +65,16 @@ export function ParishListContent({ promise }: ParishListContentProps) {
title={t("title")}
description={t("description")}
actions={
- router.push("/setting/parish/new")}
- variant="primary"
- icon={ }
- data-testid="add-parish-btn"
- >
- {t("addParish")}
-
+ canManage && (
+ router.push("/setting/parish/new")}
+ variant="primary"
+ icon={ }
+ data-testid="add-parish-btn"
+ >
+ {t("addParish")}
+
+ )
}
/>