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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 (
Back to workers
-
}>
-
+
+ {query.file && fileMessages[query.file] ? (
+
+ {fileMessages[query.file]}
+
+ ) : null}
}
+ key={`${section}:${query.month ?? ""}`}
+ fallback={}
>
-
);
}
-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]) => (
+
+ ))}
+
+
+
+ {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)}`,
+ ])}
+ />
+
+
-
-