diff --git a/docs/design-references/worker-record-overhaul/00-concept-board.png b/docs/design-references/worker-record-overhaul/00-concept-board.png new file mode 100644 index 0000000..78deaa2 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/00-concept-board.png differ diff --git a/docs/design-references/worker-record-overhaul/01-create-personal-desktop.png b/docs/design-references/worker-record-overhaul/01-create-personal-desktop.png new file mode 100644 index 0000000..2a9df2a Binary files /dev/null and b/docs/design-references/worker-record-overhaul/01-create-personal-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/01-create-personal-desktop.svg b/docs/design-references/worker-record-overhaul/01-create-personal-desktop.svg new file mode 100644 index 0000000..a9dba21 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/01-create-personal-desktop.svg @@ -0,0 +1,95 @@ + +Create worker — personal details desktop + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to workers +Create worker +Add a permanent worker record. Nothing is saved until the final review. + + +1 +Personal +Name, phone, nationality, address + + +2 +Work & pay +Rate, trade, skill, food deduction + + +3 +Documents +Identity and supporting records + + +4 +Photo +Optional profile photo + + +5 +Review +Confirm before saving +Step 1 of 5 +Personal details +Start with only the worker’s essential contact information. + +Full name * + +Enter worker’s legal name +Phone number * + +e.g. +60 12 345 6789 +Nationality * + +Select nationality +Address (optional) + +Street, city, state + + + +Keep induction focused +No identity documents on this step. +No assignment or employment controls. +No notes or alternate-phone clutter. + +Cancel + +Continue + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/02-create-documents-desktop.png b/docs/design-references/worker-record-overhaul/02-create-documents-desktop.png new file mode 100644 index 0000000..516ce2c Binary files /dev/null and b/docs/design-references/worker-record-overhaul/02-create-documents-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/02-create-documents-desktop.svg b/docs/design-references/worker-record-overhaul/02-create-documents-desktop.svg new file mode 100644 index 0000000..8c1bc11 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/02-create-documents-desktop.svg @@ -0,0 +1,142 @@ + +Create worker — documents desktop + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to workers +Create worker +Add worker documents and optional files. + + + +Personal +Name, phone, nationality, address + + + +Work & pay +Rate, trade, skill, food deduction + + +3 +Documents +Identity and supporting records + + +4 +Photo +Optional profile photo + + +5 +Review +Confirm before saving +Step 3 of 5 +Documents +Record metadata even when no file is available. At least one CNIC or Passport is required. + +Document sections +Open one section at a time + + + + + +CNIC +Required identity + + + + +Passport +Required identity + + + + +Work Permit +Recommended + + + + +CIDB Personnel Card +Optional + + + + +Medical certificate +Optional + + ++ Add another document + +CNIC +Identity document · file optional + +CNIC number * + +11•••••••77 +Issuing country + +Pakistan +Issue date + +01 Jan 2020 +Expiry date + +Not applicable +Document file (optional) + + + + +Drop a file here or browse +PDF, JPG, PNG, WEBP, HEIC, DOC or DOCX · max 10 MB + +Completion +✓ CNIC added +○ Passport empty +○ Work Permit empty +At least one identity +document is complete. + +Back + +Continue + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/03-create-review-desktop.png b/docs/design-references/worker-record-overhaul/03-create-review-desktop.png new file mode 100644 index 0000000..46f6470 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/03-create-review-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/03-create-review-desktop.svg b/docs/design-references/worker-record-overhaul/03-create-review-desktop.svg new file mode 100644 index 0000000..d0f8790 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/03-create-review-desktop.svg @@ -0,0 +1,138 @@ + +Create worker — review desktop + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to workers +Create worker +Review every section before creating the record. + + + +Personal +Name, phone, nationality, address + + + +Work & pay +Rate, trade, skill, food deduction + + + +Documents +Identity and supporting records + + + +Photo +Optional profile photo + + +5 +Review +Confirm before saving +Step 5 of 5 +Review worker details +Reaching this page does not save anything. Use Edit to return to a section. + +Personal details + +Edit +Full name +Ahmed +Phone ++60 12 345 6789 +Nationality +Pakistani +Address +Cyberjaya, Selangor + +Work & pay + +Edit +Hourly rate +RM 10.00 +Trade +Electrician +Skill level +Helper +Food deduction +RM 360.00 / month + +Documents + +Edit + + + +CNIC +11•••••••77 +No file + +Complete + + + +Passport +AB••••567 +passport.pdf + +Complete + + + +Work Permit +WP••••654 +permit.jpg + +Complete + +Worker photo + +Edit + + + +Photo selected +ahmed-profile.jpg · 1.2 MB + +Ready to create. Employment, classification, rate and deduction begin today; project remains Awaiting assignment. + +Back + +Create worker + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/04-create-flow-mobile.png b/docs/design-references/worker-record-overhaul/04-create-flow-mobile.png new file mode 100644 index 0000000..dd165fc Binary files /dev/null and b/docs/design-references/worker-record-overhaul/04-create-flow-mobile.png differ diff --git a/docs/design-references/worker-record-overhaul/04-create-flow-mobile.svg b/docs/design-references/worker-record-overhaul/04-create-flow-mobile.svg new file mode 100644 index 0000000..fe9197a --- /dev/null +++ b/docs/design-references/worker-record-overhaul/04-create-flow-mobile.svg @@ -0,0 +1,291 @@ + +Create worker — mobile five-step flow + + +Worker induction — deliberate mobile flow +No horizontal step tabs, no covered fields, and no save until review. + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Create worker + +1 + + +2 + + +3 + + +4 + + +5 +Step 1 of 5 +Personal details +Full name * + +Ahmed +Phone number * + ++60 12 345 6789 +Nationality * + +Pakistani +Address (optional) + +Cyberjaya, Selangor + + +Continue + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Create worker + + + + +2 + + +3 + + +4 + + +5 +Step 2 of 5 +Work & pay +Hourly pay rate (MYR) * + +10.00 +Trade * + +Electrician +Skill level * + +Helper +Monthly food deduction (MYR) * + +360.00 + + +Back + +Continue + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Create worker + + + + + + + +3 + + +4 + + +5 +Step 3 of 5 +Documents + + + + +CNIC +Added · 11•••••••77 + + + + + +Passport +Not added + + + + + +Work Permit +Expires 31 May 2026 + + + + + +CIDB Personnel Card +Optional + + ++ Add another document + + +Back + +Continue + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Create worker + + + + + + + + + + +4 + + +5 +Step 4 of 5 +Worker photo + + + + + +Upload worker photo +Use camera or choose an image +Optional · can be added later + + +Back + +Continue + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Create worker + + + + + + + + + + + + + +5 +Step 5 of 5 +Review details + +Personal details +Ahmed · +60 12 345 6789 +Edit + +Work & pay +Electrician · Helper · RM 10.00 +Edit + +Documents +3 sections complete +Edit + +Worker photo +Photo selected +Edit + +Ready to create +Nothing has been saved yet. + + +Back + +Create worker + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/05-edit-review-desktop.png b/docs/design-references/worker-record-overhaul/05-edit-review-desktop.png new file mode 100644 index 0000000..5e73b52 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/05-edit-review-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/05-edit-review-desktop.svg b/docs/design-references/worker-record-overhaul/05-edit-review-desktop.svg new file mode 100644 index 0000000..4dce8c5 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/05-edit-review-desktop.svg @@ -0,0 +1,111 @@ + +Edit worker — review changes desktop + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to Ahmed +Edit worker +Review the exact changes before saving. + + + +Personal +Name, phone, nationality, address + + + +Work & pay +Rate, trade, skill, food deduction + + + +Documents +Identity and supporting records + + + +Photo +Optional profile photo + + +5 +Review +Confirm before saving +Review changes +Only changed values are emphasized. Effective history is preserved. + +Changes to save + +Phone number +123456789 + ++60 12 345 6789 + +Hourly rate +RM 10.00 + +RM 11.50 + +Work Permit expiry +31 Jul 2026 + +31 Jul 2027 + +Worker photo +Current photo + +New photo selected + + +Rate change effective from +03 Aug 2026 +Edit date + +Unchanged areas + +✓ Name, nationality and address +✓ Trade, skill and food deduction +✓ CNIC and Passport +✓ Employment and assignment remain managed + from the worker profile, not this form. + +Saving updates the profile and effective records. Existing document file versions remain in history. + +Back + +Save changes + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/06-edit-review-mobile.png b/docs/design-references/worker-record-overhaul/06-edit-review-mobile.png new file mode 100644 index 0000000..120bd3f Binary files /dev/null and b/docs/design-references/worker-record-overhaul/06-edit-review-mobile.png differ diff --git a/docs/design-references/worker-record-overhaul/06-edit-review-mobile.svg b/docs/design-references/worker-record-overhaul/06-edit-review-mobile.svg new file mode 100644 index 0000000..92cbd28 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/06-edit-review-mobile.svg @@ -0,0 +1,71 @@ + +Edit worker — review changes mobile + + + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Ahmed + + + + + + + + + + + + + +5 +Review changes +Confirm before saving. + +Phone +123456789 + ++60 12 345 6789 +Edit + +Hourly rate +RM 10.00 + +RM 11.50 +Edit + +Permit expiry +31 Jul 2026 + +31 Jul 2027 +Edit + +Rate effective from +03 Aug 2026 +Edit + +Employment and project assignment +are managed on the worker profile. + + +Back + +Save changes + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.png b/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.png new file mode 100644 index 0000000..e44ae47 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.svg b/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.svg new file mode 100644 index 0000000..37a3056 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/07-worker-overview-desktop.svg @@ -0,0 +1,136 @@ + +Worker detail — desktop overview + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Overview + +Work history +Documents +Attendance & leave +Payroll +Activity + +Current project +SAFAR + +Employment +Active + +Hourly rate +RM 10.00 + +Food deduction +RM 360.00 + +Documents +1 expiring + +Personal information + +Edit section +Phone ++60 12 345 6789 + +Nationality +Pakistani + +Address +Cyberjaya, Selangor + +Primary ID +CNIC 11•••••••77 + + +Job and pay + +Edit section +Trade +Electrician + +Skill level +Helper + +Hourly rate +RM 10.00 + +Food deduction +RM 360.00 / month + + +Needs attention + + + + + +Work Permit +Expires 31 Aug 2026 +Open Documents +Recent activity +03 Aug · Phone updated +01 Aug · Attendance corrected + +Recent attendance + +Open section +03 Aug 2026 +09:00–18:20 +8h 50m + +Complete +02 Aug 2026 +Sunday +7h 30m + +Complete + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.png b/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.png new file mode 100644 index 0000000..bf352d0 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.png differ diff --git a/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.svg b/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.svg new file mode 100644 index 0000000..b1e915d --- /dev/null +++ b/docs/design-references/worker-record-overhaul/08-worker-documents-desktop.svg @@ -0,0 +1,117 @@ + +Worker detail — desktop documents + + + + +W +Worksite +Operations +OPERATIONS + +Home + +Projects + + +Workers + +Attendance + +Leave +FINANCE + +Payroll +REPORTING + +Reports +ADMIN + +Settings + +Import center + +Audit + + +Company operations +CEO workspace + +‹ Back to workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Overview +Work history +Documents + +Attendance & leave +Payroll +Activity +Documents +Metadata remains useful even when no file is attached. + ++ Add document + + + + +CNIC +11•••••••77 +No expiry +No file + +Complete + +Manage + + + + +Passport +AB••••567 +Expires 01 Jan 2030 +passport.pdf + +Complete + +Manage + + + + +Work Permit +WP••••654 +Expires 31 Aug 2026 +permit.jpg + +Expiring + +Manage + + + + +CIDB Personnel Card +CIDB••482 +Expires 12 Jun 2027 +No file + +Complete + +Manage + +Replaced and removed document history (3) + + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.png b/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.png new file mode 100644 index 0000000..c15c2ee Binary files /dev/null and b/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.png differ diff --git a/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.svg b/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.svg new file mode 100644 index 0000000..9354769 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/09-worker-overview-mobile.svg @@ -0,0 +1,68 @@ + +Worker detail — mobile Overview + + + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Overview +Change section + + +Project +SAFAR + +Rate +RM 10.00 + +Docs +1 alert + +Personal information + +Phone ++60 12 345 6789 +Nationality +Pakistani +Address +Cyberjaya, Selangor +Primary ID +CNIC 11•••••••77 + + + + +Work Permit expires soon +31 Aug 2026 · View documents + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.png b/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.png new file mode 100644 index 0000000..18d1890 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.png differ diff --git a/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.svg b/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.svg new file mode 100644 index 0000000..eeb423d --- /dev/null +++ b/docs/design-references/worker-record-overhaul/10-worker-documents-mobile.svg @@ -0,0 +1,68 @@ + +Worker detail — mobile Documents + + + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Documents +Change section + + + + + +CNIC +11•••••••77 · No file + +Complete + + + + +Passport +AB••••567 · Expires 2030 + +Complete + + + + +Work Permit +WP••••654 · Expires 31 Aug + +Expiring + ++ Add document + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.png b/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.png new file mode 100644 index 0000000..954b72d Binary files /dev/null and b/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.png differ diff --git a/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.svg b/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.svg new file mode 100644 index 0000000..00e9dda --- /dev/null +++ b/docs/design-references/worker-record-overhaul/11-worker-attendance-mobile.svg @@ -0,0 +1,64 @@ + +Worker detail — mobile Attendance & leave + + + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Attendance & leave +Change section + + +Days +2 + +Hours +16h 20m + +Issues +0 +Recent attendance + +03 Aug 2026 +09:00–18:20 +8h 50m + +02 Aug 2026 +Sunday +7h 30m + +01 Aug 2026 +Approved leave + + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/12-mobile-section-picker.png b/docs/design-references/worker-record-overhaul/12-mobile-section-picker.png new file mode 100644 index 0000000..eda9788 Binary files /dev/null and b/docs/design-references/worker-record-overhaul/12-mobile-section-picker.png differ diff --git a/docs/design-references/worker-record-overhaul/12-mobile-section-picker.svg b/docs/design-references/worker-record-overhaul/12-mobile-section-picker.svg new file mode 100644 index 0000000..c0320ad --- /dev/null +++ b/docs/design-references/worker-record-overhaul/12-mobile-section-picker.svg @@ -0,0 +1,66 @@ + +Worker detail mobile section picker + + + + + +W +Company operations +CEO workspace + + + +Home + +Projects + + +Workers + +Attendance + +More +‹ Workers + + + + + +Active +SAFAR +Ahmed +Electrician · Helper +CNIC 11•••••••77 · Joined 12 Jun 2025 + + +Edit worker + +Overview +Change section + + + + +Worker section +Choose one focused view. The page never scrolls tabs sideways. + +Overview +Current personal, work and alert summary + + +Work history +Employment, assignments, rates and deductions + +Documents +Metadata, files and expiry state + +Attendance & leave +Worker records displayed here, not a redirect + +Payroll +Monthly calculations and payments + +Activity +Worker-specific audit entries + \ No newline at end of file diff --git a/docs/design-references/worker-record-overhaul/README.md b/docs/design-references/worker-record-overhaul/README.md new file mode 100644 index 0000000..d050381 --- /dev/null +++ b/docs/design-references/worker-record-overhaul/README.md @@ -0,0 +1,34 @@ +# Worker Record Overhaul — UI Reference Pack + +These references define the intended create, edit, and worker-detail direction for the Worksite Operations Platform. They are implementation references, not pixel-perfect screenshots. Preserve the existing Worksite visual language and business permissions while matching the information architecture and responsive behavior shown here. + +## Required flow + +1. **Personal** — full name, phone number, nationality, optional address only. +2. **Work & pay** — hourly pay rate, trade, skill level, monthly food deduction only. +3. **Documents** — CNIC, Passport, Work Permit, and additional configured document types. Metadata must be savable without a file. At least one of CNIC or Passport is required. +4. **Photo** — optional worker photo with preview and replacement/removal behavior. +5. **Review** — nothing is submitted merely by entering this step. The user reviews grouped details, returns to any section through Edit actions, and explicitly creates or saves the worker. + +## Responsive rules + +- Desktop uses a compact horizontal progress header and bounded content width. +- Mobile uses numbered progress dots, one focused section, a sticky action bar above the bottom navigation, safe-area spacing, and no horizontally scrolling step labels. +- The worker detail page uses normal desktop tabs but a mobile section picker/bottom sheet. Never require the user to drag a horizontal scrollbar to find a worker section. +- Attendance, leave, payroll, and audit sections render worker-scoped content inside the worker profile rather than acting as redirects to other modules. +- Rigid page chrome renders immediately; only the changing data region uses skeleton loading. + +## Files + +- `00-concept-board.png` — generated overview board for the whole direction. +- `01-create-personal-desktop.*` — first induction step. +- `02-create-documents-desktop.*` — document section model and optional-file behavior. +- `03-create-review-desktop.*` — explicit final review and save. +- `04-create-flow-mobile.*` — all five mobile induction states. +- `05-edit-review-desktop.*` and `06-edit-review-mobile.*` — edit review with before/after changes and effective-rate date. +- `07-worker-overview-desktop.*` — compact overview information architecture. +- `08-worker-documents-desktop.*` — document rows and actions. +- `09-worker-overview-mobile.*`, `10-worker-documents-mobile.*`, `11-worker-attendance-mobile.*` — focused mobile sections. +- `12-mobile-section-picker.*` — replacement for horizontally scrolling worker tabs. + +Each `.svg` is the authoritative layout reference; the matching `.png` is provided for convenient visual inspection. diff --git a/e2e/phase3.spec.ts b/e2e/phase3.spec.ts index 9621055..1ec5bf2 100644 --- a/e2e/phase3.spec.ts +++ b/e2e/phase3.spec.ts @@ -1,5 +1,5 @@ import { clerk } from "@clerk/testing/playwright"; -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { getPhaseOneTestUser } from "./support/clerk-users"; import { @@ -7,109 +7,166 @@ import { setupPhaseThreeE2EData, } from "./support/phase3-database"; -test.beforeEach(async ({ isMobile }) => { - test.skip(Boolean(isMobile), "CEO worker creation runs in desktop Chromium."); - setupPhaseThreeE2EData(); -}); - -test.afterEach(async ({ isMobile }) => { - if (!isMobile) cleanupPhaseThreeE2EWorkers(); -}); - -test("the CEO can create, find, update, and audit a worker", async ({ - page, -}) => { - test.slow(); - const { signInTicket } = await getPhaseOneTestUser("CEO"); - const suffix = Date.now(); - const workerName = `E2E Phase 3 Worker ${suffix}`; - +async function signIn(page: Page, role: "CEO" | "FOREMAN" = "CEO") { + const { signInTicket } = await getPhaseOneTestUser(role); await page.goto("/sign-in"); await clerk.signIn({ page, signInParams: { strategy: "ticket", ticket: signInTicket }, }); - await page.goto("/ceo/workers/new"); +} - await page.getByLabel("Legal Name").fill(workerName); - await page.getByLabel("Phone Number").fill("+60123456789"); - await page.getByLabel("Nationality").fill("Pakistan"); - await page.getByLabel("Passport Number").fill(`E2E-P3-${suffix}`); +async function fillWorkerDraft( + page: Page, + workerName: string, + identifier: string, + withFiles = false, +) { + await page.goto("/ceo/workers/new"); + await page.getByLabel("Full name *").fill(workerName); + await page.getByLabel("Phone number *").fill("+60123456789"); + await page.getByLabel("Nationality *").fill("Pakistan"); await page.getByRole("button", { name: "Continue" }).click(); - await expect( - page.getByRole("heading", { - name: "Permit and classification", - exact: true, - }), - ).toBeVisible(); - - await page.getByLabel("Trade").selectOption({ label: "E2E Phase 3 Trade" }); + await page.getByLabel("Hourly pay rate (MYR) *").fill("15.50"); + await page.getByLabel("Trade *").selectOption({ label: "E2E Phase 3 Trade" }); await page - .getByLabel("Skill Level") + .getByLabel("Skill level *") .selectOption({ label: "E2E Phase 3 Skill" }); - await page.getByLabel("Monthly Food Deduction (MYR)").fill("120.00"); + await page.getByLabel("Monthly food deduction (MYR) *").fill("120.00"); await page.getByRole("button", { name: "Continue" }).click(); - await expect( - page.getByRole("heading", { - name: "Employment, rate, and assignment", - exact: true, - }), - ).toBeVisible(); - - await page.getByLabel("Employment Start Date").fill("2026-07-24"); - await page.getByLabel("Hourly Rate (MYR)").fill("15.50"); - await page.getByLabel("Rate Effective Date").fill("2026-07-24"); - await page - .getByLabel("Initial Project (Optional)") - .selectOption({ label: "E2E Phase 3 Project" }); - await page.getByLabel("Assignment Effective Date").fill("2026-07-24"); + await page.getByRole("button", { name: /Passport/ }).click(); + await page.getByLabel("Document number *").fill(identifier); + await page.getByLabel("Issue date *").fill("2026-01-01"); + await page.getByLabel("Expiry date *").fill("2031-01-01"); + if (withFiles) + await page.getByLabel("Private file (optional)").setInputFiles({ + name: "passport.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("private-test-document"), + }); + await page.getByRole("button", { name: "Continue" }).click(); + if (withFiles) + await page.getByLabel("Worker photo (optional)").setInputFiles({ + name: "worker.png", + mimeType: "image/png", + buffer: Buffer.from("89504e470d0a1a0a", "hex"), + }); await page.getByRole("button", { name: "Continue" }).click(); +} + +test.beforeEach(() => setupPhaseThreeE2EData()); +test.afterEach(() => cleanupPhaseThreeE2EWorkers()); + +test("CEO creates a metadata-only worker through five reviewed stages and edits with rate history", async ({ + page, + isMobile, +}) => { + test.skip(Boolean(isMobile), "Desktop worker lifecycle coverage."); + test.slow(); + await signIn(page); + const suffix = Date.now(); + const workerName = `E2E Phase 3 Worker ${suffix}`; + await fillWorkerDraft(page, workerName, `E2E-P3-${suffix}`); await expect( - page.getByRole("heading", { name: "Final notes", exact: true }), + page.getByRole("heading", { name: "Review worker details" }), ).toBeVisible(); - + await expect(page.getByText("No file attached")).toBeVisible(); + await expect(page).toHaveURL(/\/ceo\/workers\/new/); await page.getByRole("button", { name: "Create worker" }).click(); - await expect(page).toHaveURL(/\/ceo\/workers\/[a-f0-9-]+$/, { timeout: 20_000, }); await expect(page.getByRole("heading", { name: workerName })).toBeVisible(); + await expect(page.getByText("Awaiting assignment").first()).toBeVisible(); await expect( - page - .getByRole("heading", { name: "Assignment History" }) - .locator("..") - .getByText("E2E Phase 3 Project", { exact: true }), - ).toBeVisible(); - await expect(page.getByText("RM 15.50", { exact: true })).toBeVisible(); + page.getByRole("navigation", { name: "Worker sections" }).getByRole("link"), + ).toHaveCount(6); await page.getByRole("link", { name: "Edit worker" }).click(); - await page.getByLabel("Phone Number").fill("+60129876543"); await page.getByRole("button", { name: "Continue" }).click(); - await expect( - page.getByRole("heading", { - name: "Permit and classification", - exact: true, - }), - ).toBeVisible(); + await page.getByLabel("Hourly pay rate (MYR) *").fill("16.50"); + await expect(page.getByLabel("Effective from *")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); - await expect( - page.getByRole("heading", { name: "Final notes", exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Save worker" }).click(); - await expect(page.getByText("Worker profile saved.")).toBeVisible({ + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText(/RM 15.50/)).toBeVisible(); + await expect(page.getByText(/RM 16.50/)).toBeVisible(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page).toHaveURL(/\/ceo\/workers\/[a-f0-9-]+$/, { timeout: 20_000, }); + await page.getByRole("link", { name: "Work history" }).click(); + await expect(page.getByText("RM 15.50", { exact: false })).toBeVisible(); + await expect(page.getByText("RM 16.50", { exact: false })).toBeVisible(); +}); - await page.goto(`/ceo/workers?query=${encodeURIComponent(workerName)}`); - await expect(page.getByText(workerName).first()).toBeVisible(); +test("Review blocks duplicates until deliberate override and optional private files can be selected", async ({ + page, + isMobile, +}) => { + test.skip(Boolean(isMobile), "Desktop duplicate and file coverage."); + test.slow(); + await signIn(page); + const identifier = `E2E-DUP-${Date.now()}`; + await fillWorkerDraft( + page, + `E2E Phase 3 Worker First ${Date.now()}`, + identifier, + true, + ); + await expect(page).toHaveURL(/\/ceo\/workers\/new/); + await page.getByRole("button", { name: "Create worker" }).click(); + await expect(page).toHaveURL(/\/ceo\/workers\/[a-f0-9-]+$/, { + timeout: 20_000, + }); - await page.goto(`/ceo/audit?query=${encodeURIComponent(workerName)}`); + await fillWorkerDraft( + page, + `E2E Phase 3 Worker Duplicate ${Date.now()}`, + identifier.toLowerCase().replaceAll("-", " "), + ); + await page.getByRole("button", { name: "Create worker" }).click(); await expect( - page.getByRole("heading", { name: "Worker profile created" }).first(), + page.getByRole("link", { name: /Open possible match/ }), ).toBeVisible(); await expect( - page.getByText(`created the worker profile for ${workerName}.`, { - exact: false, - }), + page.getByRole("heading", { name: "Review worker details" }), ).toBeVisible(); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "Create worker" }).click(); + await expect(page).toHaveURL(/\/ceo\/workers\/[a-f0-9-]+$/, { + timeout: 20_000, + }); +}); + +test("worker detail uses a mobile section sheet and keeps CEO-only information out of the Foreman view", async ({ + page, + isMobile, +}) => { + test.skip(!isMobile, "Mobile responsive worker detail coverage."); + test.slow(); + await signIn(page); + const workerName = `E2E Phase 3 Worker Mobile ${Date.now()}`; + await fillWorkerDraft(page, workerName, `E2E-MOBILE-${Date.now()}`); + await page.getByRole("button", { name: "Create worker" }).click(); + await expect(page).toHaveURL(/\/ceo\/workers\/[a-f0-9-]+$/, { + timeout: 20_000, + }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + await page.getByRole("button", { name: /Section Overview/ }).click(); + await page.getByRole("link", { name: "Attendance & leave" }).click(); + await expect(page.getByRole("heading", { name: "Attendance" })).toBeVisible(); + await signIn(page, "FOREMAN"); + await page.goto("/foreman/workers"); + await page.getByRole("link", { name: /Open / }).first().click(); + await expect( + page.getByText(/Hourly rate|Food deduction|Payroll|Activity/), + ).toHaveCount(0); + await page.getByRole("button", { name: /Section/ }).click(); + await expect(page.getByRole("link", { name: "Attendance" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Leave" })).toBeVisible(); }); diff --git a/e2e/support/phase4-database.ts b/e2e/support/phase4-database.ts index 017bc7e..b049523 100644 --- a/e2e/support/phase4-database.ts +++ b/e2e/support/phase4-database.ts @@ -119,7 +119,6 @@ begin id, legal_name, phone_number, - passport_number, created_by, updated_by ) @@ -127,11 +126,14 @@ begin worker_id, '${workerName}', '+60123456789', - 'E2E-PHASE-4', ceo_id, ceo_id ); + insert into public.worker_documents (worker_id, file_kind, document_type_id, document_number) + select worker_id, 'DOCUMENT', id, 'E2E-PHASE-4' + from public.document_types where system_code = 'PASSPORT'; + insert into public.worker_employment_periods ( worker_id, status, diff --git a/e2e/support/phase5-database.ts b/e2e/support/phase5-database.ts index 8a06174..5732496 100644 --- a/e2e/support/phase5-database.ts +++ b/e2e/support/phase5-database.ts @@ -107,7 +107,6 @@ begin id, legal_name, phone_number, - passport_number, created_by, updated_by ) @@ -115,11 +114,14 @@ begin worker_id, '${workerName}', '+60123456789', - 'E2E-PHASE-5', ceo_id, ceo_id ); + insert into public.worker_documents (worker_id, file_kind, document_type_id, document_number) + select worker_id, 'DOCUMENT', id, 'E2E-PHASE-5' + from public.document_types where system_code = 'PASSPORT'; + insert into public.worker_employment_periods ( worker_id, status, diff --git a/e2e/support/phase6-database.ts b/e2e/support/phase6-database.ts index 8f2f1b6..5c789d7 100644 --- a/e2e/support/phase6-database.ts +++ b/e2e/support/phase6-database.ts @@ -139,7 +139,6 @@ begin id, legal_name, phone_number, - passport_number, created_by, updated_by ) @@ -147,11 +146,14 @@ begin worker_id, '${workerName}', '+60123456789', - 'E2E-PHASE-6', ceo_id, ceo_id ); + insert into public.worker_documents (worker_id, file_kind, document_type_id, document_number) + select worker_id, 'DOCUMENT', id, 'E2E-PHASE-6' + from public.document_types where system_code = 'PASSPORT'; + insert into public.worker_employment_periods ( worker_id, status, diff --git a/src/app/api/imports/preview/route.ts b/src/app/api/imports/preview/route.ts index 0598faf..c0ac634 100644 --- a/src/app/api/imports/preview/route.ts +++ b/src/app/api/imports/preview/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireRole } from "@/lib/auth/access"; +import { validateWorkerFile } from "@/lib/phase3/files"; import { parseImportWorkbook, type ImportIssue, @@ -28,7 +29,7 @@ const stagedFilesSchema = z .int() .min(1) .max(10 * 1024 * 1024), - type: z.enum(["application/pdf", "image/jpeg", "image/png"]), + type: z.string().trim().min(1).max(150), }), ) .max(100); @@ -46,6 +47,10 @@ export async function POST(request: Request) { const parsedStaged = stagedFilesSchema.safeParse(stagedInput); if ( !parsedStaged.success || + (parsedStaged.success && + parsedStaged.data.some( + (file) => !validateWorkerFile(file, "DOCUMENT").ok, + )) || (parsedStaged.data.length > 0 && !parsedBatchId.success) ) { return NextResponse.json( @@ -119,19 +124,26 @@ export async function POST(request: Request) { ); } - const [projects, workers, trades, skills, documentTypes] = await Promise.all([ - supabase.from("projects").select("name,client_name"), - supabase.from("workers").select("cnic_number,passport_number"), - supabase.from("trades").select("name").eq("is_active", true), - supabase.from("skill_levels").select("name").eq("is_active", true), - supabase - .from("document_types") - .select("name,expects_issue_date,expects_expiry_date") - .eq("is_active", true), - ]); + const [projects, identityDocuments, trades, skills, documentTypes] = + await Promise.all([ + supabase.from("projects").select("name,client_name"), + supabase + .from("worker_documents") + .select("document_number,document_type_id") + .eq("status", "ACTIVE") + .not("document_number", "is", null), + supabase.from("trades").select("name").eq("is_active", true), + supabase.from("skill_levels").select("name").eq("is_active", true), + supabase + .from("document_types") + .select( + "id,name,system_code,expects_document_number,expects_issue_date,expects_expiry_date", + ) + .eq("is_active", true), + ]); for (const [operation, response] of [ ["projects", projects], - ["workers", workers], + ["worker_identity_documents", identityDocuments], ["trades", trades], ["skills", skills], ["document_types", documentTypes], @@ -151,6 +163,7 @@ export async function POST(request: Request) { const lookup: ImportLookup = { documentTypes: (documentTypes.data ?? []).map((item) => ({ + expectsDocumentNumber: item.expects_document_number, expectsExpiryDate: item.expects_expiry_date, expectsIssueDate: item.expects_issue_date, name: item.name, @@ -159,11 +172,23 @@ export async function POST(request: Request) { (project) => `${project.name.trim().toLocaleLowerCase()}|${project.client_name.trim().toLocaleLowerCase()}`, ), - existingWorkerIdentifiers: (workers.data ?? []).flatMap((worker) => - [worker.cnic_number, worker.passport_number] - .filter((value): value is string => Boolean(value)) - .map((value) => value.replace(/\s+/g, "").toLocaleUpperCase()), - ), + existingWorkerIdentifiers: (identityDocuments.data ?? []) + .filter((document) => + (documentTypes.data ?? []).some( + (type) => + type.id === document.document_type_id && + ["CNIC", "PASSPORT"].includes(type.system_code ?? ""), + ), + ) + .flatMap((document) => + document.document_number + ? [ + document.document_number + .replace(/[^A-Z0-9]+/gi, "") + .toLocaleUpperCase(), + ] + : [], + ), skillNames: (skills.data ?? []).map((item) => item.name), tradeNames: (trades.data ?? []).map((item) => item.name), }; diff --git a/src/app/api/imports/uploads/route.ts b/src/app/api/imports/uploads/route.ts index 1123430..0b2ba88 100644 --- a/src/app/api/imports/uploads/route.ts +++ b/src/app/api/imports/uploads/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireRole } from "@/lib/auth/access"; +import { safeWorkerFilename, validateWorkerFile } from "@/lib/phase3/files"; import { logger } from "@/lib/server/logger"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -14,31 +15,26 @@ const fileSchema = z.object({ .int() .min(1) .max(10 * 1024 * 1024), - type: z.enum(["application/pdf", "image/jpeg", "image/png"]), + type: z.string().trim().min(1).max(150), }); const requestSchema = z.object({ files: z.array(fileSchema).max(100), }); -function safeFilename(filename: string) { - const normalized = filename - .normalize("NFKD") - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized.slice(-120) || "import-file"; -} - export async function POST(request: Request) { await requireRole("CEO"); const parsed = requestSchema.safeParse( await request.json().catch(() => null), ); - if (!parsed.success) { + if ( + !parsed.success || + parsed.data.files.some((file) => !validateWorkerFile(file, "DOCUMENT").ok) + ) { return NextResponse.json( { message: - "Attach no more than 100 PDF, JPEG, or PNG files of up to 10 MB each.", + "Attach no more than 100 allowed business files of up to 10 MB each.", }, { status: 400 }, ); @@ -63,7 +59,7 @@ export async function POST(request: Request) { const uploads = []; for (const file of parsed.data.files) { const id = randomUUID(); - const path = `imports/${batchId}/${id}-${safeFilename(file.name)}`; + const path = `imports/${batchId}/${id}-${safeWorkerFilename(file.name)}`; const signed = await supabase.storage .from("worker-documents") .createSignedUploadUrl(path); diff --git a/src/app/api/workers/[workerId]/documents/[documentId]/route.ts b/src/app/api/workers/[workerId]/documents/[documentId]/route.ts index be91fd5..b4749c2 100644 --- a/src/app/api/workers/[workerId]/documents/[documentId]/route.ts +++ b/src/app/api/workers/[workerId]/documents/[documentId]/route.ts @@ -29,7 +29,13 @@ export async function GET( .eq("id", documentId.data) .eq("worker_id", workerId.data) .maybeSingle(); - if (error || !document) { + if ( + error || + !document || + !document.bucket_id || + !document.object_path || + !document.original_filename + ) { return new Response("File not found or no longer authorized.", { status: 404, }); diff --git a/src/app/api/workers/[workerId]/documents/route.ts b/src/app/api/workers/[workerId]/documents/route.ts index 955f8f1..fe01703 100644 --- a/src/app/api/workers/[workerId]/documents/route.ts +++ b/src/app/api/workers/[workerId]/documents/route.ts @@ -1,37 +1,27 @@ -import { randomUUID } from "node:crypto"; - import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; import { requireRole } from "@/lib/auth/access"; -import { logger } from "@/lib/server/logger"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + bestEffortStorageCleanup, + uploadWorkerFile, +} from "@/lib/phase3/file-storage"; import { documentMetadataSchema } from "@/lib/phase3/validation"; +import { validateWorkerFile } from "@/lib/phase3/files"; import { uuidSchema } from "@/lib/phase2/validation"; - -const allowedDocumentTypes = new Set([ - "application/pdf", - "image/jpeg", - "image/png", -]); -const allowedPhotoTypes = new Set(["image/jpeg", "image/png"]); -const maximumBytes = 10 * 1024 * 1024; +import { logger } from "@/lib/server/logger"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; function resultRedirect(request: Request, workerId: string, result: string) { return NextResponse.redirect( - new URL(`/ceo/workers/${workerId}?file=${result}#documents`, request.url), + new URL( + `/ceo/workers/${workerId}?section=documents&file=${result}`, + request.url, + ), 303, ); } -function safeFilename(filename: string) { - const normalized = filename - .normalize("NFKD") - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized.slice(-120) || "worker-file"; -} - export async function POST( request: Request, { params }: { params: Promise<{ workerId: string }> }, @@ -45,141 +35,152 @@ export async function POST( const formData = await request.formData(); const supabase = await createServerSupabaseClient(); - const intent = formData.get("intent"); + const intent = String(formData.get("intent") ?? "save"); - if (intent === "remove") { + if (["remove", "remove-document", "remove-file"].includes(intent)) { const documentId = uuidSchema.safeParse(formData.get("documentId")); - if (!documentId.success) { + if (!documentId.success) return resultRedirect(request, workerId, "invalid"); - } - const { data: document, error: lookupError } = await supabase + const document = await supabase .from("worker_documents") .select("id,worker_id") .eq("id", documentId.data) .eq("worker_id", parsedWorkerId.data) .eq("status", "ACTIVE") .maybeSingle(); - if (lookupError || !document) { + if (document.error || !document.data) { return resultRedirect(request, workerId, "invalid"); } - const { data, error } = await supabase.rpc("remove_worker_file", { - p_document_id: document.id, + const removal = await supabase.rpc("remove_worker_document", { + p_document_id: document.data.id, + p_remove_document: intent !== "remove-file", }); - if (error || !data[0]) { - logger.error("worker_file_remove_failed", { code: error?.code }); - return resultRedirect(request, workerId, "failed"); - } - - const cleanup = await supabase.storage - .from(data[0].bucket_id) - .remove([data[0].object_path]); - if (cleanup.error) { - logger.error("worker_file_storage_cleanup_failed", { - code: cleanup.error.name, + if (removal.error || !removal.data[0]) { + logger.error("worker_document_remove_failed", { + code: removal.error?.code, }); + return resultRedirect(request, workerId, "failed"); } - revalidatePath(`/ceo/workers/${workerId}`); + const cleaned = await bestEffortStorageCleanup({ + bucketId: removal.data[0].bucket_id, + objectPath: removal.data[0].object_path, + supabase, + }); + revalidateWorker(workerId); return resultRedirect( request, workerId, - cleanup.error ? "removed-cleanup-warning" : "removed", + cleaned + ? intent === "remove-file" + ? "file-removed" + : "removed" + : "removed-cleanup-warning", ); } const metadata = documentMetadataSchema.safeParse({ - fileKind: formData.get("fileKind"), - documentTypeId: formData.get("documentTypeId"), documentNumber: formData.get("documentNumber"), - issueDate: formData.get("issueDate"), + documentTypeId: formData.get("documentTypeId"), expiryDate: formData.get("expiryDate"), + fileKind: formData.get("fileKind"), + issueDate: formData.get("issueDate"), + metadata: formData.get("metadata"), replaceDocumentId: formData.get("replaceDocumentId"), }); - const file = formData.get("file"); - if (!metadata.success || !(file instanceof File)) { - return resultRedirect(request, workerId, "invalid"); - } + if (!metadata.success) return resultRedirect(request, workerId, "invalid"); - const allowedTypes = - metadata.data.fileKind === "PHOTO" - ? allowedPhotoTypes - : allowedDocumentTypes; - if ( - file.size < 1 || - file.size > maximumBytes || - !allowedTypes.has(file.type) - ) { - return resultRedirect(request, workerId, "invalid"); - } - - const { data: worker, error: workerError } = await supabase + const worker = await supabase .from("workers") .select("id") .eq("id", parsedWorkerId.data) .maybeSingle(); - if (workerError || !worker) { + if (worker.error || !worker.data) return resultRedirect(request, workerId, "invalid"); - } - if (metadata.data.documentTypeId) { - const { data: documentType, error: typeError } = await supabase - .from("document_types") - .select("expects_issue_date,expects_expiry_date") - .eq("id", metadata.data.documentTypeId) - .eq("is_active", true) - .maybeSingle(); - if ( - typeError || - !documentType || - (documentType.expects_issue_date && !metadata.data.issueDate) || - (documentType.expects_expiry_date && !metadata.data.expiryDate) - ) { + const file = formData.get("file"); + const hasFile = file instanceof File && file.size > 0; + const fileValidation = hasFile + ? validateWorkerFile(file, metadata.data.fileKind) + : null; + if (metadata.data.fileKind === "PHOTO") { + if (!hasFile || !fileValidation?.ok) return resultRedirect(request, workerId, "invalid"); - } + const upload = await uploadWorkerFile({ + file, + kind: "PHOTO", + supabase, + workerId: worker.data.id, + }); + if (!upload.ok) return resultRedirect(request, workerId, "failed"); + revalidateWorker(workerId); + return resultRedirect(request, workerId, "photo-saved"); } - const fileId = randomUUID(); - const bucketId = - metadata.data.fileKind === "PHOTO" ? "worker-photos" : "worker-documents"; - const objectPath = `${worker.id}/${fileId}-${safeFilename(file.name)}`; - const upload = await supabase.storage - .from(bucketId) - .upload(objectPath, await file.arrayBuffer(), { - contentType: file.type, - upsert: false, - }); - if (upload.error) { - logger.error("worker_file_upload_failed", { code: upload.error.name }); - return resultRedirect(request, workerId, "failed"); + const type = await supabase + .from("document_types") + .select( + "expects_document_number,expects_issue_date,expects_expiry_date,is_active", + ) + .eq("id", metadata.data.documentTypeId!) + .maybeSingle(); + if ( + type.error || + !type.data?.is_active || + (type.data.expects_document_number && !metadata.data.documentNumber) || + (type.data.expects_issue_date && !metadata.data.issueDate) || + (type.data.expects_expiry_date && !metadata.data.expiryDate) + ) { + return resultRedirect(request, workerId, "invalid"); } - const { error } = await supabase.rpc("register_worker_file", { - p_bucket_id: bucketId, - p_byte_size: file.size, + const saved = await supabase.rpc("save_worker_document_metadata", { + p_confirm_duplicate: formData.get("confirmDuplicate") === "yes", + p_document_id: metadata.data.replaceDocumentId ?? "", p_document_number: metadata.data.documentNumber ?? "", - p_document_type_id: metadata.data.documentTypeId ?? "", + p_document_type_id: metadata.data.documentTypeId!, p_expiry_date: metadata.data.expiryDate ?? "", - p_file_kind: metadata.data.fileKind, - p_id: fileId, p_issue_date: metadata.data.issueDate ?? "", - p_mime_type: file.type, - p_object_path: objectPath, - p_original_filename: file.name, - p_replace_document_id: metadata.data.replaceDocumentId ?? "", - p_worker_id: worker.id, + p_metadata: metadata.data.metadata, + p_worker_id: worker.data.id, }); - if (error) { - await supabase.storage.from(bucketId).remove([objectPath]); - logger.error("worker_file_metadata_failed", { code: error.code }); - return resultRedirect(request, workerId, "failed"); + if (saved.error) { + logger.error("worker_document_metadata_save_failed", { + code: saved.error.code, + }); + return resultRedirect(request, workerId, "invalid"); } - revalidatePath(`/ceo/workers/${workerId}`); - revalidatePath("/ceo/workers"); - revalidatePath("/ceo"); + if (hasFile) { + if (!fileValidation?.ok) { + revalidateWorker(workerId); + return resultRedirect(request, workerId, "metadata-saved-upload-failed"); + } + const upload = await uploadWorkerFile({ + documentId: saved.data, + file, + kind: "DOCUMENT", + supabase, + workerId: worker.data.id, + }); + if (!upload.ok) { + revalidateWorker(workerId); + return resultRedirect(request, workerId, "metadata-saved-upload-failed"); + } + } + + revalidateWorker(workerId); return resultRedirect( request, workerId, - metadata.data.replaceDocumentId ? "replaced" : "uploaded", + hasFile ? "document-saved" : "metadata-saved", ); } + +function revalidateWorker(workerId: string) { + revalidatePath(`/ceo/workers/${workerId}`); + revalidatePath(`/foreman/workers/${workerId}`); + revalidatePath("/ceo/workers"); + revalidatePath("/foreman/workers"); + revalidatePath("/ceo"); +} diff --git a/src/app/ceo/settings/page.tsx b/src/app/ceo/settings/page.tsx index 5f4a4e8..d69e268 100644 --- a/src/app/ceo/settings/page.tsx +++ b/src/app/ceo/settings/page.tsx @@ -482,6 +482,14 @@ async function SettingsContent({ section }: { section: string }) { />
+ +
@@ -510,11 +526,14 @@ async function SettingsContent({ section }: { section: string }) {

{type.name}

{[ + type.expects_document_number ? "Document number" : null, type.expects_issue_date ? "Issue date" : null, type.expects_expiry_date ? "Expiry date" : null, ] .filter(Boolean) - .join(" and ") || "No dates required"}{" "} + .join(", ") || "No structured fields required"}{" "} + ·{" "} + {type.is_repeatable ? "Repeatable" : "One active record"}{" "} · {type.is_active ? "Active" : "Inactive"}

diff --git a/src/app/ceo/workers/[workerId]/edit/loading.tsx b/src/app/ceo/workers/[workerId]/edit/loading.tsx index 56874f5..61bc0c1 100644 --- a/src/app/ceo/workers/[workerId]/edit/loading.tsx +++ b/src/app/ceo/workers/[workerId]/edit/loading.tsx @@ -1,12 +1,7 @@ -import { FormRouteLoading } from "@/components/operations/route-loading"; +import { WorkerRecordRouteLoading } from "@/components/operations/route-loading"; export default function Loading() { return ( - + ); } diff --git a/src/app/ceo/workers/[workerId]/edit/page.tsx b/src/app/ceo/workers/[workerId]/edit/page.tsx index cb8e6a3..d10c786 100644 --- a/src/app/ceo/workers/[workerId]/edit/page.tsx +++ b/src/app/ceo/workers/[workerId]/edit/page.tsx @@ -4,7 +4,7 @@ import { ChevronLeft } from "lucide-react"; import { Suspense } from "react"; import { updateWorkerAction } from "@/app/ceo/workers/actions"; -import { FormContentSkeleton } from "@/components/operations/loading-skeletons"; +import { WorkerRecordFormSkeleton } from "@/components/operations/loading-skeletons"; import { WorkerForm, type WorkerFormValues, @@ -14,13 +14,23 @@ import { getWorkerIdentity, getWorkerOptions, } from "@/lib/phase3/data"; +import { malaysiaDateInputValue } from "@/lib/phase2/format"; export default async function EditWorkerPage({ params, + searchParams, }: { params: Promise<{ workerId: string }>; + searchParams: Promise<{ stage?: string }>; }) { const { workerId } = await params; + const query = await searchParams; + const initialStage = Math.max( + 0, + ["personal", "work-pay", "documents", "photo", "review"].indexOf( + query.stage ?? "personal", + ), + ); if (!(await getWorkerIdentity(workerId))) notFound(); const workerPromise = getWorkerEditDefaults(workerId); const optionsPromise = getWorkerOptions(); @@ -43,8 +53,9 @@ export default async function EditWorkerPage({ effective history.

- }> + }> @@ -54,49 +65,114 @@ export default async function EditWorkerPage({ } async function EditWorkerForm({ + initialStage, workerPromise, optionsPromise, }: { + initialStage: number; workerPromise: ReturnType; optionsPromise: ReturnType; }) { const [worker, options] = await Promise.all([workerPromise, optionsPromise]); if (!worker) return null; + if (worker.currentEmployment?.status === "ARCHIVED") { + return ( +

+ Archived workers are read-only. +

+ ); + } + + const typeById = new Map( + options.documentTypes.map((type) => [type.id, type]), + ); + const pinnedCodes = ["CNIC", "PASSPORT", "WORK_PERMIT"]; + const activeDocuments = worker.documents.filter( + (document) => document.file_kind === "DOCUMENT", + ); + const documentDrafts: WorkerFormValues["documents"] = activeDocuments.map( + (document) => { + const type = document.document_type_id + ? typeById.get(document.document_type_id) + : undefined; + const metadata = + document.metadata && + typeof document.metadata === "object" && + !Array.isArray(document.metadata) + ? Object.fromEntries( + Object.entries(document.metadata).map(([key, value]) => [ + key, + typeof value === "string" ? value : "", + ]), + ) + : {}; + return { + clientKey: crypto.randomUUID(), + documentNumber: document.document_number ?? "", + documentTypeId: document.document_type_id ?? "", + expiryDate: document.expiry_date ?? "", + file: null, + fileAction: "keep" as const, + hasFile: Boolean(document.object_path), + id: document.id, + issueDate: document.issue_date ?? "", + metadata, + originalFilename: document.original_filename ?? "", + systemCode: type?.system_code ?? null, + }; + }, + ); + for (const code of pinnedCodes) { + const type = options.documentTypes.find( + (option) => option.system_code === code, + ); + if ( + !type || + documentDrafts.some((document) => document.documentTypeId === type.id) + ) + continue; + documentDrafts.push({ + clientKey: crypto.randomUUID(), + documentNumber: "", + documentTypeId: type.id, + expiryDate: "", + file: null, + fileAction: "keep", + hasFile: false, + id: null, + issueDate: "", + metadata: {}, + originalFilename: "", + systemCode: type.system_code, + }); + } const values: WorkerFormValues = { address: worker.address ?? "", - alternatePhone: worker.alternate_phone ?? "", - assignmentStartsOn: worker.currentAssignment?.starts_on ?? "", - cnicNumber: worker.cnic_number ?? "", - employmentStartsOn: worker.currentEmployment?.starts_on ?? "", - employmentStatus: - worker.currentEmployment?.status === "ARCHIVED" - ? "LEFT_COMPANY" - : (worker.currentEmployment?.status ?? "ACTIVE"), + documents: documentDrafts, foodDeduction: ( (worker.currentDeduction?.monthly_amount_sen ?? 0) / 100 ).toFixed(2), hourlyRate: ((worker.currentRate?.hourly_rate_sen ?? 0) / 100).toFixed(2), legalName: worker.legal_name, nationality: worker.nationality ?? "", - notes: worker.notes ?? "", - passportNumber: worker.passport_number ?? "", phoneNumber: worker.phone_number, - projectId: worker.currentAssignment?.project_id ?? "", - rateStartsOn: worker.currentRate?.starts_on ?? "", + photoAction: "keep", + photoFile: null, + photoId: worker.photoId, + rateEffectiveOn: malaysiaDateInputValue(), skillLevelId: worker.currentClassification?.skill_level_id ?? "", tradeId: worker.currentClassification?.trade_id ?? "", - workPermitExpiryDate: worker.work_permit_expiry_date ?? "", - workPermitIssueDate: worker.work_permit_issue_date ?? "", - workPermitNumber: worker.work_permit_number ?? "", + workerId: worker.id, }; return ( -
+
part.charAt(0).toUpperCase() + part.slice(1)) - .join(" ") - : "No status"; -} - -function nextEmploymentStatuses(status: string | undefined) { - if (status === "ACTIVE") return ["SUSPENDED", "LEFT_COMPANY"] as const; - if (status === "SUSPENDED") return ["ACTIVE", "ARCHIVED"] as const; - if (status === "LEFT_COMPANY") return ["ACTIVE", "ARCHIVED"] as const; - return [] as const; -} - +const sections = [ + { label: "Overview", value: "overview" }, + { label: "Work history", value: "work-history" }, + { label: "Documents", value: "documents" }, + { label: "Attendance & leave", value: "attendance-leave" }, + { label: "Payroll", value: "payroll" }, + { label: "Activity", value: "activity" }, +]; const fileMessages: Record = { - failed: "The file change could not be completed. Please retry.", - invalid: "Check the file type, size, document type, and required dates.", - removed: "File removed. Its metadata remains in history.", + "document-saved": "Document metadata and private file saved.", + "file-removed": "The file was removed; document metadata remains active.", + "metadata-saved": "Document metadata saved with no file attached.", + "metadata-saved-upload-failed": + "Document metadata was saved, but the optional file upload failed. Use Manage to retry.", + removed: "Document removed; its history is retained.", "removed-cleanup-warning": - "The file was removed from the app, but Storage cleanup needs support review.", - replaced: "File replaced and the earlier version retained in history.", - uploaded: "Private file uploaded.", + "The record was removed, but private-storage cleanup needs support review.", + failed: "The file change could not be completed. Please retry.", + invalid: "Check the document metadata and selected file.", + "photo-saved": "Worker photo saved.", }; export default async function WorkerDetailPage({ @@ -77,916 +74,652 @@ export default async function WorkerDetailPage({ searchParams, }: { params: Promise<{ workerId: string }>; - searchParams: Promise<{ file?: string; tab?: string }>; + searchParams: Promise<{ + file?: string; + month?: string; + section?: string; + tab?: string; + }>; }) { const { workerId } = await params; const query = await searchParams; - const requestedTab = query.tab ?? (query.file ? "documents" : "overview"); - const tab = [ - "overview", - "employment", - "assignments", - "rates", - "documents", - "leave", - "payroll", - "audit", - ].includes(requestedTab) - ? requestedTab - : "overview"; if (!(await getWorkerIdentity(workerId))) notFound(); - const workerCorePromise = getWorkerCore(workerId); - const workerPromise = getWorkerForTab(workerId, tab, workerCorePromise); - const tabSkeletonCards = [ - "overview", - "employment", - "assignments", - "rates", - ].includes(tab) - ? 2 - : 1; - + const requested = query.section ?? query.tab ?? "overview"; + const section = sections.some((item) => item.value === requested) + ? requested + : "overview"; + const corePromise = getWorkerCore(workerId); return (
); } -async function WorkerProfileHeaderAndTabs({ +async function Header({ workerPromise, - tab, + section, }: { workerPromise: ReturnType; - tab: string; + section: string; }) { const worker = await workerPromise; if (!worker) return null; - const currentStatus = worker.currentEmployment?.status; - return ( <> -
-
-
- {worker.photoId ? ( - + + + + ); +} + +function minutes(value: number) { + return `${Math.floor(value / 60)}h ${value % 60}m`; +} + +async function Section({ + workerId, + section, + month, + corePromise, +}: { + workerId: string; + section: string; + month?: string; + corePromise: ReturnType; +}) { + const currentMonth = /^\d{4}-\d{2}$/.test(month ?? "") + ? month! + : malaysiaDateInputValue().slice(0, 7); + if (section === "attendance-leave") { + const [attendance, leave] = await Promise.all([ + getWorkerAttendanceMonth(workerId, currentMonth), + listLeaveRequests({ workerId }), + ]); + return ( +
+
+ + + +
+
+ {[ + ["Payable days", attendance.totals.payableDays], + ["Payable time", minutes(attendance.totals.payableMinutes)], + ["Normal", minutes(attendance.totals.normalMinutes)], + ["Overtime", minutes(attendance.totals.overtimeMinutes)], + ["Sunday", minutes(attendance.totals.sundayMinutes)], + ["Public holiday", minutes(attendance.totals.publicHolidayMinutes)], + ["Exceptions", attendance.totals.exceptions], + ["Leave days", attendance.totals.leaveDays], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+ {attendance.rows.length ? ( + attendance.rows.map((row) => ( +
+ {formatDate(row.date)} + + {row.projectName} + + {row.leaveTypeName ?? minutes(row.totalMinutes)} + + {row.leaveTypeName ?? + (row.exceptionCount + ? `${row.exceptionCount} exceptions` + : row.status)} + +
+ )) ) : ( - worker.legal_name - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]) - .join("") +

+ No attendance for this month. +

)}
-
-
- - {employmentLabel(currentStatus)} - - - {worker.projectName ?? "Awaiting assignment"} - -
-

- {worker.legal_name} -

-

- {worker.tradeName ?? "No trade"} ·{" "} - {worker.skillName ?? "No skill level"} -

+ + +
+ {leave.length ? ( + leave.slice(0, 30).map((item) => ( +
+ + {item.leaveTypeName} · {item.projectName} + + + {formatDate(item.starts_on)} – {formatDate(item.ends_on)} + + {item.status} +
+ )) + ) : ( +

No leave history.

+ )}
-
- {currentStatus !== "ARCHIVED" ? ( +
+
+ ); + } + if (section === "payroll") { + const history = await getWorkerPayrollHistory(workerId); + return ( +
+ +
+ {history.length ? ( + history.map((line) => ( + + + {line.run + ? payrollMonthLabel(line.run.payroll_month) + : "Payroll"} + + {formatSen(line.net_pay_sen)} + {line.payment_status} + + )) + ) : ( +

No payroll history.

+ )} +
+
+
+ ); + } + if (section === "activity") { + const entries = await getWorkerAuditEntries(workerId); + return ( +
+ +
+ {entries.length ? ( + entries.map((entry) => { + const view = presentAuditEntry({ + action: entry.action, + actorName: entry.actorName, + afterData: entry.after_data, + beforeData: entry.before_data, + entityType: entry.entity_type, + foremanName: entry.foremanName, + module: entry.module, + projectName: entry.projectName, + source: entry.source, + workerName: entry.workerName, + }); + return ( +
+
+

{view.title}

+ +
+

+ {view.summary} +

+ {view.changes.length ? ( +

+ {view.changes + .map( + (change) => + `${change.field}: ${change.from ?? "—"} → ${change.to}`, + ) + .join(" · ")} +

+ ) : null} +
+ ); + }) + ) : ( +

+ No activity recorded. +

+ )} +
+
+
+ ); + } + const worker = await getWorkerForSection(workerId, section, corePromise); + if (!worker) return null; + if (section === "documents") + return ( +
+ + + Manage + + ) : undefined + } + > + + +
+ ); + if (section === "work-history") { + const projects = await listAssignableProjects(); + return ( +
+ + + [ + formatDate(item.starts_on), + `${item.status.replaceAll("_", " ")} · until ${formatDate(item.ends_on)}`, + ])} + /> + + + [ + formatDate(item.starts_on), + `${item.projectName} · until ${formatDate(item.ends_on)}`, + ]) + : [["Current", "Awaiting assignment"]] + } + /> + + + [ + formatDate(item.starts_on), + `${item.tradeName} · ${item.skillName}`, + ])} + /> + + + [ + formatDate(item.starts_on), + `${formatSen(item.hourly_rate_sen)} · until ${formatDate(item.ends_on)}`, + ])} + /> + + + [ + formatDate(item.starts_on), + `${formatSen(item.monthly_amount_sen)} · until ${formatDate(item.ends_on)}`, + ])} + /> + +
- - - +
+
+ + Edit + + } + > + + + + Edit + + } + > + + +
+ +

+ {alertCount + ? `${alertCount} document${alertCount === 1 ? "" : "s"} need attention.` + : "No current document alerts."} +

+
+ +
); } -async function WorkerProfileContent({ +function WorkHistoryActions({ workerId, - workerPromise, - query, - tab, + status, + projects, + currentProjectId, + currentRate, }: { workerId: string; - workerPromise: ReturnType; - query: { file?: string; tab?: string }; - tab: string; + status: string; + projects: Array<{ id: string; name: string }>; + currentProjectId: string; + currentRate: number; }) { - const supportingDataPromise = Promise.all([ - tab === "assignments" - ? listAssignableProjects().then((projects) => ({ - documentTypes: [], - projects, - skills: [], - trades: [], - })) - : tab === "documents" - ? listActiveDocumentTypes().then((documentTypes) => ({ - documentTypes, - projects: [], - skills: [], - trades: [], - })) - : Promise.resolve({ - documentTypes: [], - projects: [], - skills: [], - trades: [], - }), - tab === "leave" ? listLeaveRequests({ workerId }) : Promise.resolve([]), - tab === "payroll" ? getWorkerPayrollHistory(workerId) : Promise.resolve([]), - ]); - const worker = await workerPromise; - if (!worker) return null; - const [options, leaveRequests, payrollHistory] = await supportingDataPromise; - const today = malaysiaDateInputValue(); - const currentStatus = worker.currentEmployment?.status; - const activeDocuments = worker.documents.filter( - (document) => - document.status === "ACTIVE" && document.file_kind === "DOCUMENT", - ); - const fileHistory = worker.documents.filter( - (document) => document.status !== "ACTIVE", - ); - + const statuses = + status === "ACTIVE" + ? ["SUSPENDED", "LEFT_COMPANY"] + : status === "ARCHIVED" + ? [] + : ["ACTIVE", "ARCHIVED"]; + const triggerClass = + "min-h-11 rounded-lg border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-800"; return ( - <> - {tab === "overview" ? ( -
-
-
-

- Identity and contact -

-
-
- {[ - ["Phone", worker.phone_number], - ["Alternate phone", worker.alternate_phone ?? "Not recorded"], - ["Nationality", worker.nationality ?? "Not recorded"], - ["CNIC", maskIdentifier(worker.cnic_number)], - ["Passport", maskIdentifier(worker.passport_number)], - ["Work permit", maskIdentifier(worker.work_permit_number)], - [ - "Permit issue", - worker.work_permit_issue_date - ? formatDate(worker.work_permit_issue_date) - : "Not recorded", - ], - [ - "Permit expiry", - worker.work_permit_expiry_date - ? formatDate(worker.work_permit_expiry_date) - : "Not recorded", - ], - ].map(([label, value]) => ( -
-
- {label} -
-
{value}
-
- ))} -
-
- Address and notes -
-
- {[worker.address, worker.notes] - .filter(Boolean) - .join("\n\n") || "No address or notes recorded."} -
-
-
-
- - -
- ) : null} - - {tab === "employment" ? ( -
-
-
-
-

- Employment history -

-
    - {worker.employment.map((period) => ( -
  1. -
    -

    - {employmentLabel(period.status)} -

    -

    - {formatDate(period.starts_on)} —{" "} - {formatDate(period.ends_on)} -

    -
    - {period.reason ? ( -

    - {period.reason} -

    - ) : null} -
  2. - ))} -
-
-
- ) : null} - - {tab === "assignments" ? ( -
-
-
-
-

- Assignment history -

- {worker.assignments.length === 0 ? ( -

- This worker has always been awaiting assignment. -

- ) : ( -
    - {worker.assignments.map((assignment) => ( -
  1. -

    {assignment.projectName}

    -

    - {formatDate(assignment.starts_on)} —{" "} - {formatDate(assignment.ends_on)} -

    -
  2. - ))} -
- )} -
-
- ) : null} - - {tab === "rates" ? ( -
-
-
-
-

- Rate history -

-
    - {worker.rates.map((rate) => ( -
  1. -

    - {formatSen(rate.hourly_rate_sen)} / hour -

    -

    - {formatDate(rate.starts_on)} — {formatDate(rate.ends_on)} -

    -
  2. - ))} -
-
-
- ) : null} - - {tab === "documents" ? ( -
-
-
- {query.file && fileMessages[query.file] ? ( -

- {fileMessages[query.file]} -

- ) : null} - - {currentStatus === "ARCHIVED" ? ( -

- This worker is archived. Files remain available for authorized - viewing, but the record is read-only. -

- ) : ( -
-
-
- )} - -
-

- Current documents -

- {activeDocuments.length === 0 ? ( -

- No worker documents uploaded. -

- ) : ( -
- {activeDocuments.map((document) => ( -
-
-
-
-

- {document.documentTypeName} -

- {document.expiryState === "EXPIRED" || - document.expiryState === "EXPIRING" ? ( - - - ) : null} -
-

- {document.original_filename} ·{" "} - {document.expiry_date - ? `Expires ${formatDate(document.expiry_date)}` - : "No expiry date recorded"} -

-
-
- - Open Private File -
-
- {currentStatus !== "ARCHIVED" ? ( -
- - Replace This Document - -
- - - - - - - - - Replace Document - -
-
- ) : null} -
- ))} -
- )} -
- - {fileHistory.length > 0 ? ( -
- - Replaced and Removed File History ({fileHistory.length}) - -
    - {fileHistory.map((document) => ( -
  1. - - {document.documentTypeName ?? "Worker photo"} - {" "} - - · {employmentLabel(document.status)} ·{" "} - {document.original_filename} - -
  2. - ))} -
-
- ) : null} -
- ) : null} - - {tab === "leave" ? ( -
-
-

- Full-day unpaid leave -

-

- Leave history -

-
- -
- ) : null} - - {tab === "payroll" ? ( -
-
-

- CEO-only financial history -

-

- Payroll and payments -

-
- {payrollHistory.length === 0 ? ( -

- This worker has no generated payroll history. -

- ) : ( -
- {payrollHistory.map((payroll) => ( -
-
-

- {payroll.run - ? payrollMonthLabel(payroll.run.payroll_month) - : "Payroll month"} -

-

- {payroll.run?.status === "APPROVED" - ? payroll.payment - ? "Paid in full" - : "Approved · unpaid" - : payroll.run?.status === "NEEDS_REVIEW" - ? "Needs CEO review" - : "Draft"} -

-
-
-

- {formatPayrollSen(payroll.net_pay_sen)} -

- {payroll.run ? ( - - Review calculation -
-
- ))} -
- )} -
- ) : null} - - {tab === "audit" ? ( -
-
- ) : null} - + + {formatDate(row.date)} · {row.projectName} + + {row.leaveTypeName ?? minutes(row.totalMinutes)} +
+ ))} + {attendance.rows.length === 0 ? ( +

No recent attendance.

+ ) : null} +
+ ); } diff --git a/src/app/ceo/workers/actions.ts b/src/app/ceo/workers/actions.ts index c3ceb63..4b7940d 100644 --- a/src/app/ceo/workers/actions.ts +++ b/src/app/ceo/workers/actions.ts @@ -2,9 +2,12 @@ import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; import { requireRole } from "@/lib/auth/access"; +import { + bestEffortStorageCleanup, + uploadWorkerFile, +} from "@/lib/phase3/file-storage"; import { logger } from "@/lib/server/logger"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -20,6 +23,10 @@ import { type Phase3ActionState, } from "@/lib/phase3/validation"; import { uuidSchema, type ActionState } from "@/lib/phase2/validation"; +import { + hasOptionalUploadFailures, + validateWorkerFile, +} from "@/lib/phase3/files"; async function getCeoContext() { await requireRole("CEO"); @@ -43,18 +50,20 @@ function optionalFormValue(formData: FormData, name: string) { } function workerInput(formData: FormData) { + let documents: unknown = []; + try { + documents = JSON.parse(String(formData.get("documentsJson") ?? "[]")); + } catch { + documents = null; + } return { legalName: formData.get("legalName"), phoneNumber: formData.get("phoneNumber"), - alternatePhone: optionalFormValue(formData, "alternatePhone"), address: optionalFormValue(formData, "address"), nationality: optionalFormValue(formData, "nationality"), - cnicNumber: optionalFormValue(formData, "cnicNumber"), - passportNumber: optionalFormValue(formData, "passportNumber"), - workPermitNumber: optionalFormValue(formData, "workPermitNumber"), - workPermitIssueDate: optionalFormValue(formData, "workPermitIssueDate"), - workPermitExpiryDate: optionalFormValue(formData, "workPermitExpiryDate"), - notes: optionalFormValue(formData, "notes"), + documents, + hourlyRate: formData.get("hourlyRate"), + rateEffectiveOn: optionalFormValue(formData, "rateEffectiveOn"), tradeId: formData.get("tradeId"), skillLevelId: formData.get("skillLevelId"), foodDeduction: formData.get("foodDeduction"), @@ -62,46 +71,25 @@ function workerInput(formData: FormData) { }; } -function normalizedIdentifier(value: string | null) { - return value?.replace(/\s+/g, "").toUpperCase() ?? ""; -} - async function findDuplicate( supabase: Awaited>, - cnicNumber: string | null, - passportNumber: string | null, + documents: Array<{ + documentNumber: string | null; + documentTypeId: string; + }>, excludeWorkerId?: string, ) { - const matches = []; - if (cnicNumber) { - let query = supabase - .from("workers") - .select("id,legal_name") - .eq("cnic_number", normalizedIdentifier(cnicNumber)) - .limit(1); - if (excludeWorkerId) query = query.neq("id", excludeWorkerId); - matches.push(query); - } - if (passportNumber) { - let query = supabase - .from("workers") - .select("id,legal_name") - .eq("passport_number", normalizedIdentifier(passportNumber)) - .limit(1); - if (excludeWorkerId) query = query.neq("id", excludeWorkerId); - matches.push(query); - } - if (matches.length === 0) return null; - - const results = await Promise.all(matches); - const failed = results.find((result) => result.error); - if (failed?.error) { + const result = await supabase.rpc("find_worker_identity_duplicate", { + p_documents: documents, + p_exclude_worker_id: excludeWorkerId ?? null, + }); + if (result.error) { logger.error("worker_duplicate_lookup_failed", { - code: failed.error.code, + code: result.error.code, }); throw new Error("Worker identity could not be checked."); } - return results.flatMap((result) => result.data)[0] ?? null; + return result.data[0] ?? null; } function databaseMessage(error: { code?: string; message: string }) { @@ -119,71 +107,7 @@ export async function createWorkerAction( _previousState: Phase3ActionState, formData: FormData, ): Promise { - const result = createWorkerSchema.safeParse({ - ...workerInput(formData), - employmentStatus: formData.get("employmentStatus"), - employmentStartsOn: formData.get("employmentStartsOn"), - hourlyRate: formData.get("hourlyRate"), - rateStartsOn: formData.get("rateStartsOn"), - projectId: formData.get("projectId"), - assignmentStartsOn: formData.get("assignmentStartsOn"), - }); - if (!result.success) { - return actionError( - "Check the highlighted worker details.", - result.error.flatten().fieldErrors, - ); - } - - const { supabase } = await getCeoContext(); - const duplicate = await findDuplicate( - supabase, - result.data.cnicNumber, - result.data.passportNumber, - ); - if (duplicate && !result.data.confirmDuplicate) { - return { - status: "error", - message: - "A worker with the same CNIC or passport may already exist. Open that record before deciding to continue.", - duplicateWorkerId: duplicate.id, - duplicateWorkerName: duplicate.legal_name, - }; - } - - const { data, error } = await supabase.rpc("create_worker_record", { - p_address: result.data.address ?? "", - p_alternate_phone: result.data.alternatePhone ?? "", - p_assignment_starts_on: result.data.assignmentStartsOn, - p_cnic_number: normalizedIdentifier(result.data.cnicNumber), - p_employment_starts_on: result.data.employmentStartsOn, - p_employment_status: result.data.employmentStatus, - p_food_deduction_sen: moneyToSen(result.data.foodDeduction), - p_hourly_rate_sen: moneyToSen(result.data.hourlyRate), - p_legal_name: result.data.legalName, - p_nationality: result.data.nationality ?? "", - p_notes: result.data.notes ?? "", - p_passport_number: normalizedIdentifier(result.data.passportNumber), - p_phone_number: result.data.phoneNumber, - p_project_id: result.data.projectId ?? "", - p_rate_starts_on: result.data.rateStartsOn, - p_skill_level_id: result.data.skillLevelId, - p_trade_id: result.data.tradeId, - p_work_permit_expiry_date: result.data.workPermitExpiryDate ?? "", - p_work_permit_issue_date: result.data.workPermitIssueDate ?? "", - p_work_permit_number: normalizedIdentifier(result.data.workPermitNumber), - }); - if (error) { - logger.error("worker_create_failed", { code: error.code }); - return actionError(databaseMessage(error)); - } - - revalidatePath("/ceo"); - revalidatePath("/ceo/workers"); - revalidatePath("/ceo/projects"); - revalidatePath("/foreman"); - revalidatePath("/foreman/workers"); - redirect(`/ceo/workers/${data}`); + return saveWorkerRecord(null, formData, createWorkerSchema); } export async function updateWorkerAction( @@ -191,59 +115,211 @@ export async function updateWorkerAction( _previousState: Phase3ActionState, formData: FormData, ): Promise { - const id = uuidSchema.safeParse(workerId); - const result = updateWorkerSchema.safeParse(workerInput(formData)); - if (!id.success || !result.success) { + return saveWorkerRecord(workerId, formData, updateWorkerSchema); +} + +async function saveWorkerRecord( + workerId: string | null, + formData: FormData, + schema: typeof createWorkerSchema, +): Promise { + const id = workerId ? uuidSchema.safeParse(workerId) : null; + const result = schema.safeParse(workerInput(formData)); + if ((id && !id.success) || !result.success) { return actionError( "Check the highlighted worker details.", result.success ? undefined : result.error.flatten().fieldErrors, ); } + const preflightFailures = new Map(); + for (const document of result.data.documents) { + const file = formData.get(`documentFile-${document.clientKey}`); + if (file instanceof File && file.size > 0) { + const validation = validateWorkerFile(file, "DOCUMENT"); + if (!validation.ok) + preflightFailures.set(document.clientKey, validation.message); + } + } + const preflightPhoto = formData.get("photoFile"); + if (preflightPhoto instanceof File && preflightPhoto.size > 0) { + const validation = validateWorkerFile(preflightPhoto, "PHOTO"); + if (!validation.ok) preflightFailures.set("photo", validation.message); + } + const { supabase } = await getCeoContext(); const duplicate = await findDuplicate( supabase, - result.data.cnicNumber, - result.data.passportNumber, - id.data, + result.data.documents, + id?.success ? id.data : undefined, ); if (duplicate && !result.data.confirmDuplicate) { return { status: "error", message: - "Another worker has the same CNIC or passport. Review that record before continuing.", + "A worker with the same CNIC or Passport may already exist. Review the masked match before deliberately continuing.", duplicateWorkerId: duplicate.id, duplicateWorkerName: duplicate.legal_name, }; } - const { error } = await supabase.rpc("edit_worker_profile", { + const save = await supabase.rpc("save_worker_record", { p_address: result.data.address ?? "", - p_alternate_phone: result.data.alternatePhone ?? "", - p_cnic_number: normalizedIdentifier(result.data.cnicNumber), + p_confirm_duplicate: result.data.confirmDuplicate, + p_documents: result.data.documents, p_food_deduction_sen: moneyToSen(result.data.foodDeduction), + p_hourly_rate_sen: moneyToSen(result.data.hourlyRate), p_legal_name: result.data.legalName, - p_nationality: result.data.nationality ?? "", - p_notes: result.data.notes ?? "", - p_passport_number: normalizedIdentifier(result.data.passportNumber), + p_nationality: result.data.nationality, p_phone_number: result.data.phoneNumber, + p_rate_effective_on: result.data.rateEffectiveOn ?? "", p_skill_level_id: result.data.skillLevelId, p_trade_id: result.data.tradeId, - p_work_permit_expiry_date: result.data.workPermitExpiryDate ?? "", - p_work_permit_issue_date: result.data.workPermitIssueDate ?? "", - p_work_permit_number: normalizedIdentifier(result.data.workPermitNumber), - p_worker_id: id.data, + p_worker_id: id?.success ? id.data : "", }); - if (error) { - logger.error("worker_update_failed", { code: error.code }); - return actionError(databaseMessage(error)); + if (save.error) { + logger.error("worker_record_save_failed", { code: save.error.code }); + return actionError(databaseMessage(save.error)); + } + + const saved = save.data as { + documentIds?: Record; + workerId?: string; + } | null; + const savedWorkerId = saved?.workerId; + if (!savedWorkerId) + return actionError("The saved worker could not be opened."); + + const failures: Array<{ clientKey: string; message: string }> = [ + ...preflightFailures, + ].map(([clientKey, message]) => ({ clientKey, message })); + for (const document of result.data.documents) { + if (document.fileAction === "remove" && document.id) { + const removal = await supabase.rpc("remove_worker_document", { + p_document_id: document.id, + p_remove_document: false, + }); + if (removal.error) { + failures.push({ + clientKey: document.clientKey, + message: + "The metadata was saved, but the existing file could not be removed.", + }); + } else { + await bestEffortStorageCleanup({ + bucketId: removal.data[0]?.bucket_id ?? null, + objectPath: removal.data[0]?.object_path ?? null, + supabase, + }); + } + } + const file = formData.get(`documentFile-${document.clientKey}`); + if (preflightFailures.has(document.clientKey)) continue; + if ( + document.fileAction !== "replace" || + !(file instanceof File) || + file.size === 0 + ) + continue; + const upload = await uploadWorkerFile({ + documentId: + saved?.documentIds?.[document.clientKey] ?? document.id ?? undefined, + file, + kind: "DOCUMENT", + supabase, + workerId: savedWorkerId, + }); + if (!upload.ok) { + failures.push({ clientKey: document.clientKey, message: upload.message }); + } + } + + let removedDocumentIds: string[] = []; + try { + const parsed = JSON.parse( + String(formData.get("removedDocumentIds") ?? "[]"), + ); + if (Array.isArray(parsed)) { + removedDocumentIds = parsed.filter( + (value): value is string => uuidSchema.safeParse(value).success, + ); + } + } catch { + removedDocumentIds = []; + } + if (removedDocumentIds.length > 0) { + const removedDocuments = await supabase + .from("worker_documents") + .select("bucket_id,object_path") + .in("id", removedDocumentIds) + .eq("status", "REMOVED"); + for (const document of removedDocuments.data ?? []) { + await bestEffortStorageCleanup({ + bucketId: document.bucket_id, + objectPath: document.object_path, + supabase, + }); + } + } + + const photoAction = String(formData.get("photoAction") ?? "keep"); + const currentPhotoId = uuidSchema.safeParse(formData.get("currentPhotoId")); + if (photoAction === "remove" && currentPhotoId.success) { + const removal = await supabase.rpc("remove_worker_document", { + p_document_id: currentPhotoId.data, + p_remove_document: true, + }); + if (!removal.error) { + await bestEffortStorageCleanup({ + bucketId: removal.data[0]?.bucket_id ?? null, + objectPath: removal.data[0]?.object_path ?? null, + supabase, + }); + } else { + failures.push({ + clientKey: "photo", + message: + "The profile changes were saved, but the photo could not be removed.", + }); + } + } + const photo = formData.get("photoFile"); + if (photoAction === "replace" && photo instanceof File && photo.size > 0) { + if (preflightFailures.has("photo")) { + // Metadata is already safely committed; the Review warning offers retry. + } else { + const upload = await uploadWorkerFile({ + file: photo, + kind: "PHOTO", + supabase, + workerId: savedWorkerId, + }); + if (!upload.ok) + failures.push({ clientKey: "photo", message: upload.message }); + } } revalidatePath("/ceo"); revalidatePath("/ceo/workers"); - revalidatePath(`/ceo/workers/${id.data}`); + revalidatePath(`/ceo/workers/${savedWorkerId}`); + revalidatePath("/ceo/projects"); + revalidatePath("/foreman"); revalidatePath("/foreman/workers"); - return actionSuccess("Worker profile saved."); + + return hasOptionalUploadFailures({ failed: failures, uploaded: [] }) + ? { + status: "success", + message: + "Worker metadata was saved, but one or more optional files need retrying in Documents.", + partialUploadFailures: failures, + workerId: savedWorkerId, + } + : { + ...actionSuccess( + workerId ? "Worker changes saved." : "Worker created.", + ), + workerId: savedWorkerId, + }; } export async function changeWorkerEmploymentAction( @@ -352,8 +428,10 @@ export async function createDocumentTypeAction( ): Promise { const result = documentTypeSchema.safeParse({ name: formData.get("name"), + expectsDocumentNumber: formData.get("expectsDocumentNumber"), expectsIssueDate: formData.get("expectsIssueDate"), expectsExpiryDate: formData.get("expectsExpiryDate"), + isRepeatable: formData.get("isRepeatable"), }); if (!result.success) { return actionError( @@ -364,8 +442,10 @@ export async function createDocumentTypeAction( const { actorId, supabase } = await getCeoContext(); const { error } = await supabase.from("document_types").insert({ created_by: actorId, + expects_document_number: result.data.expectsDocumentNumber, expects_expiry_date: result.data.expectsExpiryDate, expects_issue_date: result.data.expectsIssueDate, + is_repeatable: result.data.isRepeatable, name: result.data.name, updated_by: actorId, }); diff --git a/src/app/ceo/workers/new/loading.tsx b/src/app/ceo/workers/new/loading.tsx index 0cdfae7..6bd9528 100644 --- a/src/app/ceo/workers/new/loading.tsx +++ b/src/app/ceo/workers/new/loading.tsx @@ -1,12 +1,10 @@ -import { FormRouteLoading } from "@/components/operations/route-loading"; +import { WorkerRecordRouteLoading } from "@/components/operations/route-loading"; export default function Loading() { return ( - ); } diff --git a/src/app/ceo/workers/new/page.tsx b/src/app/ceo/workers/new/page.tsx index b9f352d..9de4cf8 100644 --- a/src/app/ceo/workers/new/page.tsx +++ b/src/app/ceo/workers/new/page.tsx @@ -3,39 +3,14 @@ import Link from "next/link"; import { Suspense } from "react"; import { createWorkerAction } from "@/app/ceo/workers/actions"; -import { FormContentSkeleton } from "@/components/operations/loading-skeletons"; +import { WorkerRecordFormSkeleton } from "@/components/operations/loading-skeletons"; import { WorkerForm, type WorkerFormValues, } from "@/components/phase3/worker-form"; -import { malaysiaDateInputValue } from "@/lib/phase2/format"; import { getWorkerOptions } from "@/lib/phase3/data"; export default function NewWorkerPage() { - const today = malaysiaDateInputValue(); - const values: WorkerFormValues = { - address: "", - alternatePhone: "", - assignmentStartsOn: today, - cnicNumber: "", - employmentStartsOn: today, - employmentStatus: "ACTIVE", - foodDeduction: "0.00", - hourlyRate: "", - legalName: "", - nationality: "", - notes: "", - passportNumber: "", - phoneNumber: "", - projectId: "", - rateStartsOn: today, - skillLevelId: "", - tradeId: "", - workPermitExpiryDate: "", - workPermitIssueDate: "", - workPermitNumber: "", - }; - return (
Back to workers -
+

Create worker

- Complete the permanent worker record in four guided steps. + Add a permanent worker record. Nothing is saved until the final + review.

- }> - + }> +
); } -async function NewWorkerForm({ values }: { values: WorkerFormValues }) { +async function NewWorkerForm() { const options = await getWorkerOptions(); + const pinnedTypes = ["CNIC", "PASSPORT", "WORK_PERMIT"] + .map((code) => + options.documentTypes.find((type) => type.system_code === code), + ) + .filter((type): type is (typeof options.documentTypes)[number] => + Boolean(type), + ); + const values: WorkerFormValues = { + address: "", + documents: pinnedTypes.map((type) => ({ + clientKey: crypto.randomUUID(), + documentNumber: "", + documentTypeId: type.id, + expiryDate: "", + file: null, + fileAction: "keep", + hasFile: false, + id: null, + issueDate: "", + metadata: {}, + originalFilename: "", + systemCode: type.system_code, + })), + foodDeduction: "0.00", + hourlyRate: "", + legalName: "", + nationality: "", + phoneNumber: "", + photoAction: "keep", + photoFile: null, + photoId: null, + rateEffectiveOn: "", + skillLevelId: "", + tradeId: "", + workerId: null, + }; return ( <> @@ -78,11 +90,11 @@ async function NewWorkerForm({ values }: { values: WorkerFormValues }) { before creating workers.
) : ( -
+

- {maskIdentifier( - worker.cnic_number ?? worker.passport_number, - )} + {maskIdentifier(worker.primaryIdentifier?.number)}

@@ -369,9 +367,7 @@ async function WorkerDirectory({ {worker.tradeName ?? "Not classified"}

- {maskIdentifier( - worker.cnic_number ?? worker.passport_number, - )} + {maskIdentifier(worker.primaryIdentifier?.number)}

diff --git a/src/app/foreman/workers/[workerId]/page.tsx b/src/app/foreman/workers/[workerId]/page.tsx index c2cfca5..256ebc9 100644 --- a/src/app/foreman/workers/[workerId]/page.tsx +++ b/src/app/foreman/workers/[workerId]/page.tsx @@ -1,280 +1,223 @@ -import { AlertTriangle, ArrowUpRight, ChevronLeft, Phone } from "lucide-react"; +import { ChevronLeft, Phone } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { Suspense } from "react"; import { - DetailPanelsSkeleton, ProfileHeaderSkeleton, + WorkerSectionSkeleton, } from "@/components/operations/loading-skeletons"; -import { StatusChip } from "@/components/operations/status-chip"; -import { WorkerAvatar } from "@/components/worker-avatar"; -import { formatDate } from "@/lib/phase2/format"; -import { getWorkerForTab, getWorkerIdentity } from "@/lib/phase3/data"; +import { + CompactCard, + InfoRows, + WorkerDocumentList, + WorkerProfileHeader, + WorkerSectionPicker, +} from "@/components/phase3/worker-detail"; +import { formatDate, malaysiaDateInputValue } from "@/lib/phase2/format"; +import { + getWorkerCore, + getWorkerForSection, + getWorkerIdentity, +} from "@/lib/phase3/data"; import { maskIdentifier } from "@/lib/phase3/format"; +import { getWorkerAttendanceMonth } from "@/lib/phase4/data"; +import { listLeaveRequests } from "@/lib/phase5/data"; + +const sections = [ + { label: "Overview", value: "overview" }, + { label: "Documents", value: "documents" }, + { label: "Attendance", value: "attendance" }, + { label: "Leave", value: "leave" }, +]; export default async function ForemanWorkerPage({ params, searchParams, }: { params: Promise<{ workerId: string }>; - searchParams: Promise<{ tab?: string }>; + searchParams: Promise<{ month?: string; section?: string; tab?: string }>; }) { const { workerId } = await params; const query = await searchParams; - const tab = query.tab === "documents" ? "documents" : "overview"; if (!(await getWorkerIdentity(workerId))) notFound(); - const corePromise = getWorkerForTab(workerId, "audit"); - const headerPromise = corePromise; - const contentPromise = getWorkerForTab(workerId, tab, corePromise); - + const requested = query.section ?? query.tab ?? "overview"; + const section = sections.some((item) => item.value === requested) + ? requested + : "overview"; + const corePromise = getWorkerCore(workerId); return (
); } -async function ForemanWorkerProfile({ - part, +async function Header({ workerPromise, - tab, + section, }: { - part: "content" | "header"; - workerPromise: ReturnType; - tab: "documents" | "overview"; + workerPromise: ReturnType; + section: string; }) { const worker = await workerPromise; if (!worker) return null; - - const documents = worker.documents.filter( - (document) => - document.status === "ACTIVE" && document.file_kind === "DOCUMENT", - ); - const status = worker.currentEmployment?.status - .replaceAll("_", " ") - .toLowerCase() - .replace(/^\w/, (letter) => letter.toUpperCase()); - const warningDocument = documents.find((document) => - ["EXPIRED", "EXPIRING"].includes(document.expiryState), - ); - const hasDocumentWarning = - Boolean(warningDocument) || - ["EXPIRED", "EXPIRING"].includes(worker.documentWarning); - return ( <> - {part === "header" ? ( - <> -
- -
-
-

- {worker.legal_name} -

- - {status ?? "Not recorded"} - -
-

- {worker.tradeName ?? "No trade"} ·{" "} - {worker.skillName ?? "No skill level"} + + + + ); +} + +function minutes(value: number) { + return `${Math.floor(value / 60)}h ${value % 60}m`; +} + +async function Content({ + workerId, + section, + month, + corePromise, +}: { + workerId: string; + section: string; + month?: string; + corePromise: ReturnType; +}) { + if (section === "leave") { + const leave = await listLeaveRequests({ workerId }); + return ( +

+ +
+ {leave.length ? ( + leave.map((item) => ( +
+ {item.leaveTypeName} + + {formatDate(item.starts_on)} – {formatDate(item.ends_on)} + + {item.status} +
+ )) + ) : ( +

+ No leave records for this project.

-

- Project: {worker.projectName ?? "No current project"} + )} +

+
+
+ ); + } + if (section === "attendance") { + const selectedMonth = /^\d{4}-\d{2}$/.test(month ?? "") + ? month! + : malaysiaDateInputValue().slice(0, 7); + const attendance = await getWorkerAttendanceMonth(workerId, selectedMonth); + return ( +
+
+ + + +
+ +
+ {attendance.rows.length ? ( + attendance.rows.map((row) => ( +
+ {formatDate(row.date)} + {row.projectName} + {row.leaveTypeName ?? minutes(row.totalMinutes)} +
+ )) + ) : ( +

+ No attendance for this month.

-
+ )}
- - {hasDocumentWarning ? ( - -
+ ); + } + const worker = await getWorkerForSection(workerId, section, corePromise); + if (!worker) return null; + if (section === "documents") + return ( +
+ + + +
+ ); + return ( +
+ + - {label} - - ))} - - - ) : ( - <> - {tab === "overview" ? ( -
-

- Worker overview -

-
- {[ - ["Nationality", worker.nationality ?? "Not recorded"], - [ - "Identifier", - maskIdentifier( - worker.cnic_number ?? worker.passport_number, - ), - ], - ["Address", worker.address ?? "Not recorded"], - ].map(([label, value]) => ( -
-
- {label} -
-
- {value} -
-
- ))} - -
-
- ) : null} - - {tab === "documents" ? ( -
-

- Worker documents -

- {documents.length === 0 ? ( -

- No current worker documents are recorded. -

- ) : ( -
    - {documents.map((document) => ( -
  1. -
    -
    -

    - {document.documentTypeName} -

    -

    - {document.expiry_date - ? `Expires ${formatDate(document.expiry_date)}` - : "No expiry date recorded"} -

    - {["EXPIRED", "EXPIRING"].includes( - document.expiryState, - ) ? ( -

    -

    - ) : null} -
    - -
    -
  2. - ))} -
- )} -
- ) : null} - -

- This is a read-only operational profile. Pay, deductions, transfers, - and administrative controls are available only to the CEO. -

- - )} - + + {worker.phone_number} + , + ], + ["Nationality", worker.nationality ?? "Not recorded"], + ["Identifier", maskIdentifier(worker.primaryIdentifier?.number)], + ["Address", worker.address ?? "Not recorded"], + ["Project", worker.projectName ?? "Not assigned"], + ["Trade", worker.tradeName ?? "Not recorded"], + ["Skill", worker.skillName ?? "Not recorded"], + ]} + /> +
+
); } diff --git a/src/app/foreman/workers/page.tsx b/src/app/foreman/workers/page.tsx index 7e11134..c27a9da 100644 --- a/src/app/foreman/workers/page.tsx +++ b/src/app/foreman/workers/page.tsx @@ -148,7 +148,7 @@ async function ForemanWorkerResults({

{worker.tradeName ?? "No trade"} ·{" "} {worker.skillName ?? "No skill level"} ·{" "} - {maskIdentifier(worker.cnic_number ?? worker.passport_number)} + {maskIdentifier(worker.primaryIdentifier?.number)}

diff --git a/src/components/operations/loading-skeletons.tsx b/src/components/operations/loading-skeletons.tsx index 2074440..3708dfe 100644 --- a/src/components/operations/loading-skeletons.tsx +++ b/src/components/operations/loading-skeletons.tsx @@ -417,6 +417,100 @@ export function FormContentSkeleton({ fields = 6 }: { fields?: number }) { ); } +export function WorkerRecordFormSkeleton() { + return ( + +
+ {Array.from({ length: 5 }, (_, index) => ( +
+ + +
+ ))} +
+
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+
+ + +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ + +
+ ))} +
+
+
+ + +
+
+ ); +} + +export function WorkerSectionSkeleton({ section }: { section: string }) { + if (section.includes("attendance")) { + return ( + + +
+ {Array.from({ length: 8 }, (_, index) => ( +
+ + +
+ ))} +
+ +
+ ); + } + if (section === "documents") { + return ( + +
+ +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); + } + return ( + + ); +} + export function ReportContentSkeleton() { return ( diff --git a/src/components/operations/route-loading.tsx b/src/components/operations/route-loading.tsx index 31e0c31..abea8a3 100644 --- a/src/components/operations/route-loading.tsx +++ b/src/components/operations/route-loading.tsx @@ -17,6 +17,7 @@ import { ProjectSummarySkeleton, ReportContentSkeleton, SettingsContentSkeleton, + WorkerRecordFormSkeleton, } from "@/components/operations/loading-skeletons"; import { PageHeader } from "@/components/operations/page-header"; import { Skeleton } from "@/components/ui/skeleton"; @@ -161,27 +162,49 @@ export function WorkerDetailRouteLoading({ {foreman ? "Workers" : "Back to workers"} -
+
{(foreman - ? ["Overview", "Documents"] + ? ["Overview", "Documents", "Attendance", "Leave"] : [ "Overview", - "Employment", - "Assignments", - "Rates", + "Work history", "Documents", - "Attendance", - "Leave", + "Attendance & leave", "Payroll", - "Audit", + "Activity", ] ).map((label) => ( - - {label} - + ))}
- + + + + ); +} + +export function WorkerRecordRouteLoading({ + title, + backLabel, +}: { + title: string; + backLabel: string; +}) { + return ( +
+ + + {backLabel} + +
+

+ {title} +

+

+ Nothing is saved until the final review. +

+
+
); } diff --git a/src/components/phase3/worker-detail/document-list.tsx b/src/components/phase3/worker-detail/document-list.tsx new file mode 100644 index 0000000..2e216ca --- /dev/null +++ b/src/components/phase3/worker-detail/document-list.tsx @@ -0,0 +1,239 @@ +import { FileText } from "lucide-react"; +import Link from "next/link"; + +import { StatusChip } from "@/components/operations/status-chip"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { workerDocumentAccept } from "@/lib/phase3/files"; +import type { WorkerDetail } from "@/lib/phase3/data"; +import { formatDate } from "@/lib/phase2/format"; +import { maskIdentifier } from "@/lib/phase3/format"; + +export function WorkerDocumentList({ + canManage, + worker, +}: { + canManage: boolean; + worker: WorkerDetail; +}) { + const active = worker.documents.filter( + (item) => item.file_kind === "DOCUMENT" && item.status === "ACTIVE", + ); + const history = worker.documents.filter((item) => item.status !== "ACTIVE"); + return ( +
+ {active.length ? ( + active.map((document) => ( +
+
+
+
+

+ {maskIdentifier(document.document_number)} · Issued{" "} + {document.issue_date + ? formatDate(document.issue_date) + : "not recorded"}{" "} + · Expires{" "} + {document.expiry_date + ? formatDate(document.expiry_date) + : "not recorded"} +

+ {document.metadata && + typeof document.metadata === "object" && + !Array.isArray(document.metadata) ? ( +

+ {Object.entries(document.metadata) + .filter(([, value]) => value) + .map( + ([key, value]) => + `${key.replaceAll("_", " ")}: ${String(value)}`, + ) + .join(" · ")} +

+ ) : null} +

+ {document.object_path ? "Attached" : "No file attached"} +

+
+
+ {document.object_path ? ( + + Open + + ) : null} + {canManage ? ( + + + Manage + + + + + {document.documentTypeName ?? "Document"} + + + Edit structured metadata in the reviewed worker flow, or + manage this private file. + + +
+ + Edit metadata + +
+ + + + + + + + + + +
+ {document.object_path ? ( +
+ + + +
+ ) : null} +
+ + + +
+
+
+
+ ) : null} +
+
+ )) + ) : ( +

No active documents.

+ )} + {history.length ? ( +
+ + Replaced and removed history ({history.length}) + +
    + {history.map((item) => ( +
  • + {item.file_kind === "PHOTO" + ? "Worker photo" + : (item.documentTypeName ?? "Document")}{" "} + · {item.status} · {formatDate(item.created_at.slice(0, 10))} +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/phase3/worker-detail/index.ts b/src/components/phase3/worker-detail/index.ts new file mode 100644 index 0000000..c47bcfd --- /dev/null +++ b/src/components/phase3/worker-detail/index.ts @@ -0,0 +1,3 @@ +export * from "./document-list"; +export * from "./primitives"; +export * from "./section-picker"; diff --git a/src/components/phase3/worker-detail/primitives.tsx b/src/components/phase3/worker-detail/primitives.tsx new file mode 100644 index 0000000..76b7785 --- /dev/null +++ b/src/components/phase3/worker-detail/primitives.tsx @@ -0,0 +1,90 @@ +import { Pencil } from "lucide-react"; +import Link from "next/link"; + +import { StatusChip } from "@/components/operations/status-chip"; +import { WorkerAvatar } from "@/components/worker-avatar"; +import type { WorkerSummary } from "@/lib/phase3/data"; +import { maskIdentifier } from "@/lib/phase3/format"; + +export function WorkerProfileHeader({ + canEdit, + worker, +}: { + canEdit: boolean; + worker: WorkerSummary; +}) { + const status = worker.currentEmployment?.status ?? "Not recorded"; + return ( +
+ +
+
+

+ {worker.legal_name} +

+ + {status.replaceAll("_", " ")} + +
+

+ {worker.projectName ?? "Awaiting assignment"} ·{" "} + {worker.tradeName ?? "No trade"} · {worker.skillName ?? "No skill"} +

+

+ Primary ID {maskIdentifier(worker.primaryIdentifier?.number)} +

+
+ {canEdit && status !== "ARCHIVED" ? ( + +
+ ); +} + +export function InfoRows({ rows }: { rows: React.ReactNode[][] }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ ); +} + +export function CompactCard({ + children, + title, + action, +}: { + children: React.ReactNode; + title: string; + action?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} diff --git a/src/components/phase3/worker-detail/section-picker.test.tsx b/src/components/phase3/worker-detail/section-picker.test.tsx new file mode 100644 index 0000000..9071300 --- /dev/null +++ b/src/components/phase3/worker-detail/section-picker.test.tsx @@ -0,0 +1,31 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { WorkerSectionPicker } from "./section-picker"; + +afterEach(cleanup); + +describe("WorkerSectionPicker", () => { + it("provides a full-width mobile picker without a horizontal tab scroller", () => { + render( + , + ); + const trigger = screen.getByRole("button", { name: /Section Documents/i }); + expect(trigger).toHaveClass("w-full"); + fireEvent.click(trigger); + expect( + screen.getByRole("navigation", { name: "Choose worker section" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Attendance & leave/ }), + ).toHaveAttribute("href", "/ceo/workers/worker-1?section=attendance-leave"); + }); +}); diff --git a/src/components/phase3/worker-detail/section-picker.tsx b/src/components/phase3/worker-detail/section-picker.tsx new file mode 100644 index 0000000..dc10eb3 --- /dev/null +++ b/src/components/phase3/worker-detail/section-picker.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import Link from "next/link"; + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +export type WorkerSection = { label: string; value: string }; + +export function WorkerSectionPicker({ + active, + basePath, + sections, +}: { + active: string; + basePath: string; + sections: WorkerSection[]; +}) { + const selected = sections.find((section) => section.value === active)!; + return ( + <> + + + + + + Section + + {selected.label} + + + + + Worker sections + Choose what you want to view. + + + + + + ); +} diff --git a/src/components/phase3/worker-form.tsx b/src/components/phase3/worker-form.tsx index 91a0f64..eeb2f15 100644 --- a/src/components/phase3/worker-form.tsx +++ b/src/components/phase3/worker-form.tsx @@ -1,496 +1,6 @@ -"use client"; - -import Link from "next/link"; -import { useActionState, useState } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Spinner } from "@/components/ui/spinner"; -import { Textarea } from "@/components/ui/textarea"; -import { type Phase3ActionState } from "@/lib/phase3/validation"; - -type Option = { id: string; name: string }; -type WorkerAction = ( - state: Phase3ActionState, - formData: FormData, -) => Promise; - -export type WorkerFormValues = { - address: string; - alternatePhone: string; - assignmentStartsOn: string; - cnicNumber: string; - employmentStartsOn: string; - employmentStatus: "ACTIVE" | "SUSPENDED" | "LEFT_COMPANY"; - foodDeduction: string; - hourlyRate: string; - legalName: string; - nationality: string; - notes: string; - passportNumber: string; - phoneNumber: string; - projectId: string; - rateStartsOn: string; - skillLevelId: string; - tradeId: string; - workPermitExpiryDate: string; - workPermitIssueDate: string; - workPermitNumber: string; -}; - -const initialState: Phase3ActionState = { status: "idle", message: "" }; - -function FieldError({ - field, - state, -}: { - field: string; - state: Phase3ActionState; -}) { - const error = state.errors?.[field]?.[0]; - return error ?

{error}

: null; -} - -export function WorkerForm({ - action, - mode, - projects, - skills, - trades, - values, -}: { - action: WorkerAction; - mode: "create" | "edit"; - projects: Option[]; - skills: Option[]; - trades: Option[]; - values: WorkerFormValues; -}) { - const [state, formAction, pending] = useActionState(action, initialState); - const [step, setStep] = useState(0); - const create = mode === "create"; - const steps = create - ? ["Identity", "Work details", "Employment", "Notes"] - : ["Identity", "Work details", "Notes"]; - const lastStep = steps.length - 1; - - return ( -
- - - - - - - {create ? ( - - ) : null} - -