Compare commits

...

43 commits

Author SHA1 Message Date
Arthur Bassi
4440f7d087 chore(work-orders): merge origin/dev into SH-109 inline save branch
EOF
2026-08-12 09:33:49 -03:00
Alexandre Brandizzi
7366f549e5
Merge pull request #45 from Sea-Haven-Industries/feature/wo-detail-comp-doc
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): completion document UI and completedDate board contract
2026-08-11 15:56:04 -03:00
Arthur Bassi
017820ac4b fix(work-orders): add completedDate to unscheduled highlight fixture
Keep the merged origin/dev test building against WorkOrderTableRow after
the CompDoc completedDate field landed on this branch.
2026-08-11 15:35:42 -03:00
Arthur Bassi
1005186ea7 Merge branch 'dev' into feature/wo-detail-comp-doc 2026-08-11 15:23:55 -03:00
Alexandre Brandizzi
2cf33ca6c1
Merge pull request #114 from Sea-Haven-Industries/feat/sh-101-uplift-lifecycle
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(uplifts): complete vendor approval lifecycle (SH-101)
2026-08-11 15:01:09 -03:00
Arthur Bassi
969f99a576 fix(work-orders): send workOrderVersion on media categorize/delete
Align CompDoc media mutations with backend PR #47 so categorize and
delete no longer return 422 WorkOrderVersionRequired.
2026-08-11 14:52:41 -03:00
Adam Moussa
4949508d43
Merge branch 'dev' into feat/sh-101-uplift-lifecycle 2026-08-11 13:35:55 -04:00
Arthur Bassi
f35fce9bd5
feat(work-orders): comment @mentions autocomplete (SH-217) (#91)
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add mentions in work-order comments composer

* docs(work-orders): align pr description with jira and qa residuals

* refactor(workorders): split work order view page into view components

* fix(work-orders): export comment assignment vendor input types for view hook

* fix(work-orders): drop preferredContact from vendor update payload

Align with WorkOrderVendorUpdatePayload on current dev so the mentions branch typechecks.

* fix(work-orders): remove Preferred Contact from WO vendor editor (SH-180)

EOF

* fix(work-orders): harden comment mentions and clear draft on success

* fix(work-orders): harden mention highlight boundary and submit catch [SH-217]

* fix(work-orders): keep mention cursor sync on current value [SH-217]

* fix(work-orders): keep mention menu open after typing @

Pass live text into syncCursor instead of a render-time valueRef.
Add a composer interaction regression for @ autocomplete visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:07:02 -03:00
Arthur Bassi
a30ac73d29
feat(work-orders): draft technician until vendor dialog save (SH-193) (#89)
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add technician affordance in wo vendor dialog

* fix(work-orders): defer technician create until vendor dialog save

* docs(work-orders): align pr description with jira and qa residuals

* fix(vendors): harden draft technician optional field typing

* refactor(workorders): extract vendor dialog body footer and save hook

* fix(work-orders): coerce null company vendor for add-technician prop

* fix(work-orders): preserve draft technician staging on save [SH-193]

* fix(work-orders): select technicians by stable id [SH-193]

Keep selectedKey as explicit dialog state (SiteDialog pattern) so same-name drafts do not collide with existing vendors, and cover the regression with interaction tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:01:31 -03:00
Alexandre Brandizzi
b177987081
Merge branch 'dev' into feat/sh-101-uplift-lifecycle 2026-08-11 13:52:26 -03:00
Arthur Bassi
9c7b42e96b
feat(work-orders): SH-132 FE — Due Date distinct from Schedule On + Emergency calendar modal (#82)
* feat(work-orders): align Due Date and Past Due with SH-132 FE contract [SH-132]

* fix(work-orders): highlight Schedule On for unscheduled rows

Restore forceScheduleHighlight for Unscheduled section rows while keeping Due Date highlight for past-due and attempt feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(work-orders): cover unscheduled Schedule On highlight [SH-132]

Add row-level public-interface regression proving Unscheduled rows force-highlight Schedule On, not Due Date (SH-165).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 13:49:41 -03:00
Alexandre Brandizzi
95fb97ea3f
Merge branch 'dev' into feat/sh-101-uplift-lifecycle 2026-08-11 13:49:38 -03:00
Alexandre Brandizzi
34ebad390f fix(uplifts): align revise validation contract 2026-08-11 13:49:03 -03:00
dependabot[bot]
ced5405746
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#113)
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Bumps the npm_and_yarn group with 3 updates in the / directory: [fast-uri](https://github.com/fastify/fast-uri), [js-yaml](https://github.com/nodeca/js-yaml) and [postcss](https://github.com/postcss/postcss).


Updates `fast-uri` from 3.1.4 to 3.1.5
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

Updates `js-yaml` from 4.3.0 to 4.3.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

Updates `postcss` from 8.5.18 to 8.5.26
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.18...8.5.26)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.26
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 10:21:42 -03:00
Arthur Bassi
f66284d78c Merge remote-tracking branch 'origin/dev' into feature/wo-detail-comp-doc 2026-08-11 09:47:20 -03:00
Alexandre Brandizzi
889812f90f
Merge branch 'dev' into feat/sh-101-uplift-lifecycle 2026-08-11 09:02:37 -03:00
Alexandre Brandizzi
47272a0afc
Merge pull request #97 from Sea-Haven-Industries/feat/sh-98-portal-lifecycle-recovered
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(vendor-portal): complete request lifecycle (SH-98)
2026-08-11 09:01:54 -03:00
Alexandre Brandizzi
c0e203a090 feat(uplifts): complete SH-101 vendor workflow 2026-08-11 08:57:38 -03:00
Alexandre Brandizzi
bd12351c87
Merge branch 'dev' into feat/sh-98-portal-lifecycle-recovered 2026-08-11 08:53:28 -03:00
Alexandre Brandizzi
1cba10b9a8
Merge pull request #93 from Sea-Haven-Industries/feat/sh-237-sh-238
fix(vendors): complete shell and visual parity gates
2026-08-11 08:52:51 -03:00
Alexandre Brandizzi
bc5659eb2a fix(vendor-portal): clear stale refusal state 2026-08-10 18:25:57 -03:00
arthur.bassi
767f0df43f Merge remote-tracking branch 'origin/dev' into feature/wo-detail-comp-doc 2026-08-10 18:13:40 -03:00
Alexandre Brandizzi
3f4d64d7b6 fix: guard pending refusal dialog 2026-08-10 13:31:12 -03:00
Alexandre Brandizzi
4d2ee40d60 feat(vendor-portal): complete request lifecycle (SH-98) 2026-08-10 13:16:26 -03:00
Alexandre Brandizzi
cc0df8f569 fix(vendors): complete shell and visual parity gates 2026-08-10 12:11:38 -03:00
arthur.bassi
cd4b8d27fd fix(work-orders): seed CompDoc address after location settles [SH-116] 2026-08-06 10:14:29 -03:00
arthur.bassi
bfc230d421 fix(work-orders): harden CompDoc generate seed persist and blob revoke 2026-08-05 10:54:33 -03:00
arthur.bassi
7ea0d2c464 fix(work-orders): refresh CompDoc on Generate failure and drop EditWorkorder date helper
Invalidate board/location queries when Save Draft or Generate fails so partial
prerequisite writes stay authoritative, and remove the unused CompDoc
mapCompletedDateUpdateFormData EditWorkorder helper.
2026-08-04 16:27:59 -03:00
arthur.bassi
6481d9ce86 test(work-orders): assert CompDoc Generate skips Yes when date patch fails 2026-08-04 15:09:19 -03:00
arthur.bassi
65d75ca372 fix(work-orders): persist CompDoc prerequisites before Generated status
Also register retry AbortControllers so cancel aborts the active upload attempt.
2026-08-04 15:03:28 -03:00
Alexandre Brandizzi
0027ecc41f Merge remote-tracking branch 'origin/dev' into feature/wo-detail-comp-doc 2026-08-04 10:30:37 -03:00
Alexandre Brandizzi
509b3c3212 fix(work-orders): persist completedDate via board and media sh-116
leave EditWorkorder for completedDate; await mutations before draft saved
toast; upload-then-categorize with MIME allowlist, progress, cancel, retry.
2026-07-31 12:24:31 -03:00
Arthur Bassi
27c7c8df55 test(work-orders): align table mutations test with updateCompletedDate
Assert updateCompletedDate instead of getById before EditWorkorder.
2026-07-31 09:58:34 -03:00
Arthur Bassi
96f3daf20c fix(work-orders): persist completedDate without legacy GetWorkorderById
Save Draft failed on GetWorkorderById (HTTP 400) before EditWorkorder.
Post only Id + CompletedDate and cover the EditWorkorder route contract.
2026-07-31 09:43:23 -03:00
Arthur Bassi
b2d44944ab fix(work-orders): resolve CompDoc/dev merge type and maintainability issues 2026-07-30 11:09:05 -03:00
Arthur Bassi
e3aa69e577 chore(work-orders): sync feature/wo-detail-comp-doc with current dev 2026-07-30 10:59:22 -03:00
Arthur Bassi
d6179fcec5 refactor(work-orders): extract WoTableRow under maintainability limits 2026-07-30 10:40:55 -03:00
Arthur Bassi
3004d039aa fix(work-orders): address SH-116 CompDoc review blockers
Gate CompDoc on selected service, use real pm titles only,
make Address editable via location update, and wire media
to the board API with categories instead of blob state.
2026-07-30 09:04:03 -03:00
Arthur Bassi
2d64c34898 fix(work-orders): extract docStatus mapper for governance ratchet
Keep work-order-mapper under the frozen line cap and cap addDispatchSignoff params.
2026-07-29 09:31:15 -03:00
Arthur Bassi
6869ef70ee chore(work-orders): sync feature/wo-detail-comp-doc with current dev 2026-07-29 09:24:51 -03:00
Arthur Bassi
188499155e fix(work-orders): persist docStatus via board PATCH with NotNeeded mapping
Map API NotNeeded to UI NN on read. Emit board PATCH Yes|No|NotNeeded on
write, and keep DocStatus off the EditWorkorder payload.
2026-07-29 09:23:34 -03:00
Arthur Bassi
b7056f569e fix(work-orders): persist completion doc fields without race
Map docStatus through the real update payload, serialize concurrent
table patches per work order, and split CompDoc UI for governance.
2026-07-28 12:49:51 -03:00
Arthur Bassi
725efd2703 feat(work-orders): slim completion-doc UI onto current dev
Keep SH-116 CompDocDialog wiring and completedDate board contract.
Drop slide-over ownership (SH-112) so it stays in PR #32.
2026-07-27 09:47:46 -03:00
194 changed files with 11122 additions and 1381 deletions

View file

@ -41,3 +41,25 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify
vendor-visual-regression:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: vendor-visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
/coverage
/test-results
/playwright-report
/playwright-report-visual
/blob-report
/playwright/.cache

View file

@ -2,6 +2,7 @@ dist
build
node_modules
coverage
playwright-report-visual
package-lock.json
*.tsbuildinfo
@ -10,4 +11,4 @@ src/pages/**
src/**/*.js
src/**/*.jsx
src/**/*.css
!src/styles/globals.css
!src/styles/globals.css

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,221 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const PORTAL_TOKEN = "portal-e2e-token";
const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
interface DispatchDetail {
id: number;
dispatchNumber: string;
workOrderTitle?: string;
internalWONumber?: string;
status: string;
nteAmount?: number;
scheduledDate?: string;
poNumber?: string;
dispatchedAt?: string;
completedDate?: string;
refusedAt?: string;
statusUpdatedAt?: string;
description?: string;
workOrder?: Record<string, unknown>;
location?: Record<string, unknown>;
dispatcherContact?: { name?: string; email?: string; phone?: string };
checklist: never[];
comments: never[];
upliftRequests: never[];
signoffs: never[];
documents: never[];
}
const sentDetail: DispatchDetail = {
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
poNumber: "PO-555",
statusUpdatedAt: "2026-08-09T16:30:00Z",
description: "Boiler is leaking; vendor visit required.",
workOrder: {
workerOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
trade: "Plumbing",
subTrade: "Backflow Preventers",
siteCode: "STL-01",
service: "Boiler Repair",
},
location: {
name: "Central Distribution Center",
address: "100 Industrial Pkwy",
city: "St. Louis",
state: "MO",
zipCode: "63101",
},
dispatcherContact: {
name: "Dana Field",
email: "dana@seahavenind.com",
phone: "314-555-0142",
},
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
};
const listDispatches = [
{
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
locationName: "Central Distribution Center",
locationCity: "St. Louis",
locationState: "MO",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
},
{
id: 102,
dispatchNumber: "DSP-102",
workOrderTitle: "HVAC quarterly service",
status: "Refused",
refusedAt: "2026-08-08T10:00:00Z",
},
{
id: 103,
dispatchNumber: "DSP-103",
workOrderTitle: "PastAcknowledged job",
status: "Acknowledged",
scheduledDate: pastDate,
},
];
async function fulfill(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) {
const detailState: { current: DispatchDetail } = { current: { ...sentDetail } };
await page.route("**/api/vendor-portal/session", (route) => {
if (options.sessionError) {
fulfill(route, { message: "Invalid or expired token" }, 401);
return;
}
fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } });
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => {
const body = route.request().postDataJSON() as { reason?: string };
detailState.current = {
...detailState.current,
status: "Refused",
refusedAt: new Date().toISOString(),
};
fulfill(route, {
data: {
id: detailState.current.id,
status: "Refused",
refusedAt: detailState.current.refusedAt,
reason: body?.reason,
},
});
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) =>
fulfill(route, { data: detailState.current }),
);
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
fulfill(route, { data: listDispatches }),
);
return detailState;
}
test.describe("Vendor portal lifecycle", () => {
test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({
page,
}) => {
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible();
await page.getByRole("button", { name: /^Upcoming/ }).click();
await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible();
await expect(page.getByText("DSP-103")).toHaveCount(0);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible();
await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible();
await expect(page.getByText("STL-01")).toBeVisible();
await expect(page.getByText("$1250.00").first()).toBeVisible();
await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible();
await expect(page.getByText("Dana Field")).toBeVisible();
await expect(page.getByText("314-555-0142")).toBeVisible();
await expect(page.getByText(/Status updated/i)).toBeVisible();
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week.");
await expect(dialog.getByText(/\d+\/500/)).toBeVisible();
await dialog.getByRole("button", { name: "Refuse dispatch" }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0);
});
test("covers the refuse dialog at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
await page
.getByRole("dialog", { name: "Refuse dispatch" })
.getByRole("button", {
name: "Refuse dispatch",
})
.click();
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
});
test("shows an expired-token state when the session is rejected", async ({ page }) => {
await mockPortalApi(page, { sessionError: true });
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText(/invalid or expired/i);
});
});

View file

@ -0,0 +1,356 @@
import { expect, test, type Route } from "@playwright/test";
const INTERNAL_USER = {
token: "uplift-approver-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "approver@seahavenind.com",
userRoles: "Admin,Tier2Approver",
fullname: "Uplift Approver",
id: "1",
};
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }),
});
}
test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => {
let requestBody: Record<string, unknown> | undefined;
let uploadPurpose = "";
let created = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") {
uploadPurpose = request.postData() ?? "";
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Pending",
reviewStatus: "Processing",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/documents/22/status")) {
await fulfillJson(route, {
id: 22,
originalFileName: "estimate.pdf",
scanStatus: "Passed",
reviewStatus: "Approved",
purpose: "UpliftEvidence",
});
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
created = true;
await fulfillJson(route, {
id: 31,
status: "Pending",
noApprovalRequired: false,
});
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
poNumber: "PO-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: created
? [
{
id: 31,
status: "Pending",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Replacement compressor",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
notificationStatus: "Sent",
},
]
: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible();
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
});
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(uploadPurpose).toContain('name="purpose"');
expect(uploadPurpose).toContain("UpliftEvidence");
expect(requestBody).toMatchObject({
requestedNTE: 125,
reason: "Replacement compressor",
evidenceDocumentId: 22,
});
expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i);
});
test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => {
let withdrawnPath = "";
let pending = true;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) {
withdrawnPath = path;
pending = false;
await fulfillJson(route, { id: 31, status: "Withdrawn" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: pending ? "Pending" : "Withdrawn",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
},
],
});
return;
}
await fulfillJson(route, []);
});
page.on("dialog", (dialog) => void dialog.accept());
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Withdraw request" }).click();
await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible();
expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw");
});
test("vendor receives the no-approval result for a request within the current NTE", async ({
page,
}) => {
let requestBody: Record<string, unknown> | undefined;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") {
requestBody = request.postDataJSON();
await fulfillJson(route, { noApprovalRequired: true, status: "Approved" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByLabel("New total NTE").fill("90");
await expect(page.getByLabel("Reason")).toHaveCount(0);
await page.getByRole("button", { name: "Request uplift" }).click();
await expect(
page.getByText("No approval required — the existing NTE already covers this amount."),
).toBeVisible();
expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" });
expect(requestBody).not.toHaveProperty("evidenceDocumentId");
});
test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => {
let revisionBody: Record<string, unknown> | undefined;
let revised = false;
await page.route("**/api/vendor-portal/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/session")) {
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
return;
}
if (path.endsWith("/dispatches/7/uplift-request/31/revise")) {
revisionBody = request.postDataJSON();
revised = true;
await fulfillJson(route, { id: 31, status: "Pending" });
return;
}
if (path.endsWith("/dispatches/7")) {
await fulfillJson(route, {
id: 7,
dispatchNumber: "DSP-7",
status: "In Progress",
nteAmount: 100,
checklist: [],
comments: [],
signoffs: [],
documents: [],
upliftRequests: [
{
id: 31,
status: revised ? "Pending" : "ChangesRequested",
currentNTE: 100,
requestedNTE: 125,
vendorReason: "Parts",
decisionNote: "Itemize the estimate",
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
},
],
});
return;
}
await fulfillJson(route, []);
});
await page.goto("/v/portal-token/dispatch/7");
await page.getByRole("button", { name: "Revise" }).click();
await page.getByLabel("New total NTE").fill("120");
await page.getByLabel("Reason").fill("Parts itemized in the attached estimate");
await page.getByRole("button", { name: "Submit revision" }).click();
await expect(page.getByText("Pending", { exact: true })).toBeVisible();
expect(revisionBody).toEqual({
requestedNTE: 120,
reason: "Parts itemized in the attached estimate",
evidenceDocumentId: 22,
});
});
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
const decisions: Array<{ path: string; body: unknown }> = [];
const statuses = new Map<number, string>([
[41, "Pending"],
[42, "Pending"],
]);
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
INTERNAL_USER,
);
await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {}));
await page.route("**/api/User*", (route) => fulfillJson(route, []));
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
if (action && request.method() === "POST") {
const id = Number(action[1]);
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
decisions.push({ path, body: request.postDataJSON() });
await fulfillJson(route, { id, status: statuses.get(id) });
return;
}
await fulfillJson(route, {
total: 2,
items: [
{
id: 41,
dispatchId: 7,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
],
});
});
await page.goto("/uplifts");
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve" }).click();
await page.getByRole("button", { name: "Confirm" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Request Changes" }).click();
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Confirm" }).click();
await expect
.poll(() => decisions)
.toEqual([
{ path: "/api/uplifts/41/approve", body: {} },
{
path: "/api/uplifts/42/request-changes",
body: { note: "Please itemize labor and parts." },
},
]);
});

View file

@ -556,6 +556,20 @@ test.describe("Vendor directory prototype parity", () => {
await mockVendorApi(page, { listError: true });
await page.goto("/vendors");
const mainBox = await page.getByRole("main").boundingBox();
expect(mainBox).toMatchObject({ x: 0, width: 390 });
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
await expect(navigation).not.toBeVisible();
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
await openNavigation.focus();
await page.keyboard.press("Enter");
await expect(navigation).toBeVisible();
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(navigation).not.toBeVisible();
await expect(openNavigation).toBeFocused();
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
@ -569,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click();
});
test("keeps protected shell destinations available and marks their selected state", async ({
page,
}) => {
await mockVendorApi(page);
for (const destination of [
{ path: "/workorders", label: "Work Orders" },
{ path: "/accounts", label: "Accounts" },
{ path: "/locations", label: "Sites" },
{ path: "/notifications", label: "Notifications" },
]) {
await page.goto(destination.path);
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
"aria-current",
"page",
);
}
});
});

261
e2e/vendors/vendors.visual.spec.ts vendored Normal file
View file

@ -0,0 +1,261 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-visual-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor Visual Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyId: 103,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (!url.pathname.startsWith("/api/")) {
await route.continue();
return;
}
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
await fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
});
return;
}
if (url.pathname.endsWith("/api/Vendor/facets")) {
await fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
});
return;
}
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
const isCount = url.searchParams.get("pageSize") === "1";
if (mode === "error" && !isCount) {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const source =
mode === "empty"
? []
: url.searchParams.get("isActive") === "false"
? inactiveVendors
: activeVendors;
await fulfillJson(route, {
Data: isCount ? source.slice(0, 1) : source,
TotalCount: source.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
return;
}
if (url.pathname.startsWith("/api/vendor-company-roster")) {
await fulfillJson(route, {
companyId: 101,
rowVersion: "rv-visual",
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "314-555-0198",
email: "dispatch@gateway.test",
preferredContact: "Email",
tradeSpecialties: "Plumbing, Backflow Preventers",
isActive: true,
totalJobs: 72,
},
],
});
return;
}
await fulfillJson(route, { data: {} });
});
}
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
await mockVendorApi(page, mode);
await page.goto("/vendors");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `
*, *::before, *::after { transition: none !important; animation: none !important; }
.MuiDrawer-paper { transform: none !important; }
.Toastify__toast-container { display: none !important; }
.tsqd-parent-container { display: none !important; }
`,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await page.screenshot({ animations: "disabled", caret: "hide" });
await expect(page).toHaveScreenshot(name);
}
test.describe("Vendor deterministic pixel regression", () => {
test("list", async ({ page }) => {
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-list.png");
});
test("filter", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await expectStableScreenshot(page, "vendor-filter.png");
});
test("add", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await expectStableScreenshot(page, "vendor-add.png");
});
test("detail", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
await expectStableScreenshot(page, "vendor-detail.png");
});
test("edit", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
await expectStableScreenshot(page, "vendor-edit.png");
});
test("inactive", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expectStableScreenshot(page, "vendor-inactive.png");
});
test("empty", async ({ page }) => {
await openVendorPage(page, "empty");
await expect(page.getByText("No active technicians found")).toBeVisible();
await expectStableScreenshot(page, "vendor-empty.png");
});
test("error", async ({ page }) => {
await openVendorPage(page, "error");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/server error|vendor directory unavailable/i,
);
await expectStableScreenshot(page, "vendor-error.png");
});
test("mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile.png");
await page.getByRole("button", { name: "Toggle navigation" }).click();
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
});
});

26
package-lock.json generated
View file

@ -4048,9 +4048,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@ -4578,9 +4578,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@ -5302,9 +5302,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -5561,9 +5561,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@ -5580,7 +5580,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -10,6 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",

View file

@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.visual.spec.ts",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
outputDir: "test-results/visual",
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
// Keep the changed-pixel budget strict while ignoring sub-perceptual
// antialiasing differences between local and hosted container runtimes.
maxDiffPixels: 5,
threshold: 0.1,
},
},
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4174",
colorScheme: "light",
deviceScaleFactor: 1,
locale: "en-US",
launchOptions: {
args: ["--disable-gpu", "--font-render-hinting=none"],
},
reducedMotion: "reduce",
timezoneId: "UTC",
trace: "retain-on-failure",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command: "npm run dev -- --port 4174 --strictPort",
url: "http://localhost:4174",
reuseExistingServer: false,
timeout: 120_000,
},
});

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = getSidebarWidth(navCollapsed);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
const handleToggleNav = () => {
if (isDesktop) {
setNavCollapsed((value) => !value);
return;
}
setMobileNavOpen((value) => !value);
};
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} />
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"

View file

@ -27,28 +27,319 @@ import {
Tooltip,
Typography,
} from "@mui/material";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
type UpliftActionType = "approve" | "reject" | "request-changes";
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
function formatMoney(value: number | null | undefined): string {
return `$${Number(value ?? 0).toFixed(2)}`;
}
function formatBytes(value: number | null | undefined): string {
if (value == null) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function UpliftQueueHeader({
totalCount,
isFetching,
isLoading,
}: {
totalCount: number;
isFetching: boolean;
isLoading: boolean;
}) {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
);
}
function UpliftQueueFilters({
status,
tier,
onStatusChange,
onTierChange,
}: {
status: string;
tier: string;
onStatusChange: (value: string) => void;
onTierChange: (value: string) => void;
}) {
return (
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
);
}
function UpliftQueueRow({
row,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
row: UpliftQueueItem;
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
const decisionable = row.status === "Pending";
return (
<TableRow hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>
{row.evidenceDocumentId != null ? (
<Stack direction="column" spacing={0.5}>
<Typography variant="caption">{row.evidenceFileName}</Typography>
{Boolean(row.evidenceContentType) && (
<Typography variant="caption" color="text.secondary">
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
</Typography>
)}
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
Download
</Button>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">
—
</Typography>
)}
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
View
</Button>
)}
{decisionable && (
<>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => onDecide("approve", row)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => onDecide("reject", row)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="outlined"
disabled={!row.canDecide}
onClick={() => onDecide("request-changes", row)}
>
Request Changes
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
);
}
function UpliftQueueTable({
isLoading,
rows,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
isLoading: boolean;
rows: UpliftQueueItem[];
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Evidence</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<UpliftQueueRow
key={String(row.id)}
row={row}
onViewWorkOrder={onViewWorkOrder}
onDownloadEvidence={onDownloadEvidence}
onDecide={onDecide}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}
function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: UpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
: action?.type === "reject"
? "Reject Uplift"
: "Request Changes"}
</DialogTitle>
<DialogContent>
{action?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
value={note}
required={noteRequired}
onChange={(e) => onNoteChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
color={action?.type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
onClick={onConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}
export default function UpliftQueuePage() {
const navigate = useNavigate();
const [status, setStatus] = useState("Pending");
const [tier, setTier] = useState("");
const [page, setPage] = useState(0);
const [pageSize] = useState(25);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
item: UpliftQueueItem;
} | null>(null);
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
const [note, setNote] = useState("");
const listParams = useMemo(
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift();
const denyUplift = useDenyUplift();
const rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const handleStatusChange = (value: string) => {
setStatus(value);
setPage(0);
};
const handleTierChange = (value: string) => {
setTier(value);
setPage(0);
};
const closeDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleConfirm = () => {
if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const id = upliftAction.item.id;
const onDone = () => {
closeDialog();
void refetch();
};
if (upliftAction.type === "approve") {
approveUplift.mutate(
{ id: upliftAction.item.id, note: note || undefined },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
} else {
if (!note.trim()) return;
denyUplift.mutate(
{ id: upliftAction.item.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
}
};
const downloadEvidence = async (item: UpliftQueueItem) => {
try {
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
}
};
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
setUpliftAction({ type, item });
setNote("");
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select
label="Tier"
value={tier}
onChange={(e) => {
setTier(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
<UpliftQueueFilters
status={status}
tier={tier}
onStatusChange={handleStatusChange}
onTierChange={handleTierChange}
/>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Delta</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>{formatMoney(row.delta)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button
size="small"
variant="outlined"
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
>
View
</Button>
)}
{row.status === "Pending" && (
<>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "approve", item: row });
setNote("");
}}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "deny", item: row });
setNote("");
}}
>
Deny
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<UpliftQueueTable
isLoading={isLoading}
rows={rows}
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
onDownloadEvidence={(r) => void downloadEvidence(r)}
onDecide={handleDecide}
/>
<TablePagination
component="div"
count={totalCount}
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve"
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
: "Deny Uplift"}
</DialogTitle>
<DialogContent>
{upliftAction?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(upliftAction.item.currentNTE)} →{" "}
{formatMoney(upliftAction.item.requestedNTE)} (+
{formatMoney(upliftAction.item.delta)})
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
<UpliftDecisionDialog
action={upliftAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeDialog}
onConfirm={handleConfirm}
/>
</Box>
);
}

View file

@ -1,90 +1,82 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
import { toast } from "react-toastify";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import {
PoDetailsPaper,
SitePaper,
UpliftDecisionDialog,
UpliftHistoryPaper,
VendorSummaryPaper,
WorkOrdersPaper,
type UpliftActionType,
type VendorPoUpliftDecision,
} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections";
function formatMoney(value: number | null | undefined): string {
if (value == null) return "—";
return `$${Number(value).toFixed(2)}`;
}
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
export default function VendorPoViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
const approveUplift = useApproveUplift(id);
const denyUplift = useDenyUplift(id);
const rejectUplift = useRejectUplift(id);
const requestChangesUplift = useRequestChangesUplift(id);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
const [note, setNote] = useState("");
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const closeUpliftDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleUpliftConfirm = () => {
if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const upliftId = upliftAction.uplift.id;
const onDone = () => {
closeUpliftDialog();
void refetch();
};
if (upliftAction.type === "approve") {
approveUplift.mutate(
{ id: upliftAction.uplift.id, note: note || undefined },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
} else {
if (!note.trim()) return;
denyUplift.mutate(
{ id: upliftAction.uplift.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
}
};
const downloadEvidence = async (uplift: UpliftRequest) => {
try {
await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
}
};
const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => {
setUpliftAction({ type, uplift });
setNote("");
};
if (isLoading) {
return (
<Box className="flex justify-center p-10">
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
</Stack>
<Stack spacing={3}>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor
</Typography>
{vendor ? (
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="Company">{vendor.companyName}</InfoRow>
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
<InfoRow label="Email">{vendor.email}</InfoRow>
<InfoRow label="Phone">{vendor.phone}</InfoRow>
<InfoRow label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
{dispatch.primaryLocation != null && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
<InfoRow label="Address">
{[
dispatch.primaryLocation.address,
dispatch.primaryLocation.city,
dispatch.primaryLocation.state,
dispatch.primaryLocation.zipCode,
]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
)}
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dispatch.workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button
variant="text"
size="small"
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack
direction="row"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{Boolean(u.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>
)}
{u.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
onClick={() => {
setUpliftAction({ type: "approve", uplift: u });
setNote("");
}}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
onClick={() => {
setUpliftAction({ type: "deny", uplift: u });
setNote("");
}}
>
Deny
</Button>
</Stack>
)}
</Box>
))}
</Stack>
)}
</Paper>
<VendorSummaryPaper vendor={vendor} />
<PoDetailsPaper dispatch={dispatch} />
<SitePaper location={dispatch.primaryLocation} />
<WorkOrdersPaper
workOrders={dispatch.workOrders}
onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
/>
<UpliftHistoryPaper
upliftRequests={dispatch.upliftRequests}
onDownload={(uplift) => void downloadEvidence(uplift)}
onDecide={handleDecide}
/>
</Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
required={upliftAction?.type === "deny"}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleUpliftConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
<UpliftDecisionDialog
action={upliftAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeUpliftDialog}
onConfirm={handleUpliftConfirm}
/>
</Box>
);
}

View file

@ -0,0 +1,341 @@
import { type ReactNode } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type {
VendorPoDetail,
VendorPoLocation,
VendorPoVendor,
VendorPoWorkOrder,
} from "@/domain/vendor-pos/types/vendor-po";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
export type UpliftActionType = "approve" | "reject" | "request-changes";
export type VendorPoUpliftDecision = { type: UpliftActionType; uplift: UpliftRequest };
function formatMoney(value: number | null | undefined): string {
if (value == null) return "—";
return `$${Number(value).toFixed(2)}`;
}
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
export function VendorSummaryPaper({ vendor }: { vendor: VendorPoVendor | null }) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor
</Typography>
{vendor ? (
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="Company">{vendor.companyName}</InfoRow>
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
<InfoRow label="Email">{vendor.email}</InfoRow>
<InfoRow label="Phone">{vendor.phone}</InfoRow>
<InfoRow label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
);
}
export function PoDetailsPaper({ dispatch }: { dispatch: VendorPoDetail }) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
);
}
export function SitePaper({ location }: { location: VendorPoLocation | null }) {
if (location == null) return null;
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{location.name}</InfoRow>
<InfoRow label="Address">
{[location.address, location.city, location.state, location.zipCode]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
);
}
export function WorkOrdersPaper({
workOrders,
onView,
}: {
workOrders: VendorPoWorkOrder[];
onView: (workOrderId: string | number) => void;
}) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({workOrders.length})
</Typography>
{workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button variant="text" size="small" onClick={() => onView(wo.workOrderId)}>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
);
}
function UpliftHistoryItem({
uplift,
onDownload,
onDecide,
}: {
uplift: UpliftRequest;
onDownload: (uplift: UpliftRequest) => void;
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
}) {
const decisionable = uplift.status === "Pending";
return (
<Box className="rounded border border-border p-3">
<Stack direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="body2" className="font-semibold">
{formatMoney(uplift.currentNTE)} → {formatMoney(uplift.requestedNTE)}
<Typography component="span" variant="caption" color="text.secondary" className="ml-2">
(Tier {uplift.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={uplift.status} />
</Stack>
{Boolean(uplift.expiresAt) && (
<Typography variant="caption" color="text.secondary" className="mt-1 block">
Expires {formatDateTime(uplift.expiresAt)}
</Typography>
)}
{Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{uplift.evidenceDocumentId != null && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
<Typography variant="body2">
<strong>Evidence:</strong>{" "}
{uplift.evidenceFileName || `Document #${uplift.evidenceDocumentId}`}
</Typography>
<Button size="small" variant="text" onClick={() => onDownload(uplift)}>
Download
</Button>
</Stack>
)}
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-1">
<strong>Decision:</strong> {uplift.decisionNote}
</Typography>
)}
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
<Typography variant="body2" color="error" className="mt-1">
Notification error: {uplift.notificationError}
</Typography>
)}
{decisionable && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
disabled={!uplift.canDecide}
onClick={() => onDecide("approve", uplift)}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
disabled={!uplift.canDecide}
onClick={() => onDecide("reject", uplift)}
>
Reject
</Button>
<Button
size="small"
variant="outlined"
disabled={!uplift.canDecide}
onClick={() => onDecide("request-changes", uplift)}
>
Request Changes
</Button>
</Stack>
)}
</Box>
);
}
export function UpliftHistoryPaper({
upliftRequests,
onDownload,
onDecide,
}: {
upliftRequests: UpliftRequest[];
onDownload: (uplift: UpliftRequest) => void;
onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void;
}) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({upliftRequests.length})
</Typography>
{upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{upliftRequests.map((uplift) => (
<UpliftHistoryItem
key={String(uplift.id)}
uplift={uplift}
onDownload={onDownload}
onDecide={onDecide}
/>
))}
</Stack>
)}
</Paper>
);
}
export function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: VendorPoUpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? "Approve Uplift"
: action?.type === "reject"
? "Reject Uplift"
: "Request Changes"}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
value={note}
required={noteRequired}
onChange={(e) => onNoteChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
color={action?.type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
onClick={onConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -41,6 +41,8 @@ export function VendorsListToolbar({
onChange={onSearchChange}
placeholder="Search name, company, trade…"
aria-label="Search vendors"
fullWidth
className="w-full md:w-56"
inputClassName="h-8 py-0 text-[13px]"
/>
<ToolbarButton
@ -49,7 +51,7 @@ export function VendorsListToolbar({
onClick={onOpenFilters}
aria-label="Open vendor filters"
badge={appliedFilterCount}
className="w-[88.609375px] text-[13px]"
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
>
Filters
</ToolbarButton>
@ -57,7 +59,7 @@ export function VendorsListToolbar({
variant="primary"
icon={<Plus size={16} />}
onClick={onAddVendor}
className="w-[115.546875px] border-0 px-3 text-[13px]"
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
>
Add Vendor
</ToolbarButton>

View file

@ -1,39 +1,21 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useParams } from "react-router";
import { Alert, Box, CircularProgress } from "@mui/material";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import { VendorEditorDialog } from "./_components/vendor-editor-dialog";
import { useVendorEditor } from "./_components/use-vendor-editor";
import { WorkOrderActivity } from "./_components/work-order-activity";
import {
DispatchAccordion,
WorkOrderDetailsSection,
WorkOrderHeader,
WorkOrderSummaryAccordions,
} from "./_components/work-order-view-sections";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
const view = useWorkOrderViewPage(workOrderId);
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
if (isLoading) {
if (view.isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
@ -41,11 +23,11 @@ export default function WorkOrderViewPage() {
);
}
if (error || !wo) {
if (view.error || !view.wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
{view.error instanceof Error ? view.error.message : "Work order not found."}
</Alert>
</Box>
);
@ -53,56 +35,67 @@ export default function WorkOrderViewPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<WorkOrderHeader
wo={wo}
onSelectStatus={(status) => statusMutation.mutate(status)}
onDispatch={() => setDispatchOpen(true)}
onEdit={() => navigate(`/workorders/edit/${id}`)}
onBack={() => navigate("/workorders")}
/>
<WorkOrderDetailsSection
wo={wo}
users={users}
onAssign={(payload) => assignmentMutation.mutate(payload)}
/>
<WorkOrderSummaryAccordions wo={wo} />
<DispatchAccordion
wo={wo}
onEditVendor={(event, vendorId, vendorName) =>
void vendorEditor.open(event, vendorId, vendorName)
}
onViewDispatch={setDispatchDetailId}
/>
<WorkOrderActivity
<WorkOrderViewHeader
wo={view.wo}
workOrderId={workOrderId}
comments={wo.comments ?? []}
auditLog={wo.auditLog ?? []}
statusAnchor={view.statusAnchor}
onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={(status) => view.statusMutation.mutate(status)}
onOpenDispatch={() => view.setDispatchOpen(true)}
/>
{dispatchOpen && (
<WorkOrderViewDetails
wo={view.wo}
users={view.users}
assignAnchor={view.assignAnchor}
onAssignAnchorChange={view.setAssignAnchor}
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
/>
<WorkOrderViewAccordions wo={view.wo} />
<WorkOrderDispatchesAccordion
wo={view.wo}
onOpenDispatchDetail={view.setDispatchDetailId}
onOpenVendorEditor={view.openVendorEditor}
/>
<WorkOrderActivityPanel
customerComments={view.customerComments}
internalComments={view.internalComments}
auditLog={view.auditLog}
mentionUsers={view.mentionUsers}
isSubmitting={view.commentMutation.isPending}
onSubmitComment={view.submitComment}
/>
{view.dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={() => void refetch()}
workOrder={view.wo}
onClose={() => view.setDispatchOpen(false)}
onDispatched={() => void view.refetch()}
/>
)}
{dispatchDetailId != null && (
{view.dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
dispatchId={view.dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onUpdated={() => void refetch()}
onClose={() => view.setDispatchDetailId(null)}
onUpdated={() => void view.refetch()}
/>
)}
<VendorEditorDialog
editor={vendorEditor.editor}
loading={vendorEditor.loading}
error={vendorEditor.error}
isPending={vendorEditor.isPending}
phoneInvalid={vendorEditor.phoneInvalid}
isValid={vendorEditor.isValid}
onClose={vendorEditor.close}
onSave={vendorEditor.save}
onFieldChange={vendorEditor.updateField}
<WorkOrderVendorEditorDialog
editor={view.vendorEditor}
loading={view.vendorEditorLoading}
error={view.vendorEditorError}
isSaving={view.vendorMutation.isPending}
onClose={() => {
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
}}
onSave={view.saveVendorEditor}
onEditorChange={view.setVendorEditor}
/>
</Box>
);

View file

@ -0,0 +1,19 @@
import type { ElementType } from "react";
type CompDocCoLineProps = {
icon: ElementType;
text: string;
bold?: boolean;
};
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
return (
<div
className="flex items-center justify-end gap-1.5 break-words"
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
>
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
</div>
);
}

View file

@ -0,0 +1,39 @@
import type { ElementType } from "react";
type CompDocDetailRowProps = {
icon: ElementType;
label: string;
value: string;
last?: boolean;
};
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
return (
<div
className="flex items-start gap-2.5"
style={{
padding: "10px 0",
borderBottom: last ? "none" : "1px solid var(--color-border)",
}}
>
<Icon
size={15}
className="shrink-0"
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
/>
<div className="min-w-0">
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--color-text-primary)",
marginTop: 1,
}}
>
{value}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditableRow } from "./comp-doc-editable-row";
type CompDocDetailsSectionProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string;
onDateCompletedChange: (value: string) => void;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
};
export function CompDocDetailsSection({
row,
site,
editAddress,
onAddressChange,
editDateCompleted,
onDateCompletedChange,
amazonRep,
seaRep,
isGenerated,
}: CompDocDetailsSectionProps) {
return (
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocEditableRow
label="Address"
value={editAddress}
onChange={onAddressChange}
placeholder={site.fullAddress !== "—" ? site.fullAddress : "Street address"}
disabled={isGenerated}
/>
<CompDocEditableRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
disabled={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
import { Ban, Save, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterDraftProps = {
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraft({
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,35 @@
import { CheckCircle2, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterGeneratedProps = {
generatedAt: string;
onDownloadPdf: () => void;
};
export function CompDocDialogFooterGenerated({
generatedAt,
onDownloadPdf,
}: CompDocDialogFooterGeneratedProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} /> Generated {generatedAt}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,47 @@
import { Ban, Sparkles, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterNotRequiredProps = {
onUndo: () => void;
};
export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onUndo}
>
<Undo2 size={14} /> Undo
</Button>
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,47 @@
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
type CompDocDialogFooterProps = {
docState: DocState;
generatedAt: string;
onMarkNotRequired: () => void;
onUndo: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
onDownloadPdf: () => void;
};
export function CompDocDialogFooter({
docState,
generatedAt,
onMarkNotRequired,
onUndo,
onSaveDraft,
onGenerate,
onDownloadPdf,
}: CompDocDialogFooterProps) {
return (
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
{docState === "draft" && (
<CompDocDialogFooterDraft
onMarkNotRequired={onMarkNotRequired}
onSaveDraft={onSaveDraft}
onGenerate={onGenerate}
/>
)}
{docState === "generated" && (
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
)}
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { FileText, X } from "lucide-react";
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = {
row: WorkOrderTableRow;
cfg: CompletionDocStateConfig;
onClose: () => void;
};
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{row.woNumber}
</span>
{" · "}
{serviceTitle(row)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,128 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar";
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const {
site,
notes,
setNotes,
docState,
generatedAt,
editAddress,
setEditAddress,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
} = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1024px, calc(100vw - 32px))",
maxWidth: "none",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent
dividers={false}
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
sx={{
p: 0,
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<CompDocDialogHeader row={row} cfg={cfg} onClose={() => onOpenChange(false)} />
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
<CompDocSidebar
row={row}
site={site}
docState={docState}
generatedAt={generatedAt}
notes={notes}
editAddress={editAddress}
onAddressChange={setEditAddress}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
onNotesChange={setNotes}
onDateCompletedChange={setEditDateCompleted}
/>
</div>
<CompDocDialogFooter
docState={docState}
generatedAt={generatedAt}
onMarkNotRequired={handleMarkNotRequired}
onUndo={handleUndo}
onSaveDraft={handleSaveDraft}
onGenerate={handleGenerate}
onDownloadPdf={handleDownloadPdf}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,29 @@
import { CheckCircle2 } from "lucide-react";
import { toast } from "react-toastify";
export function showCompDocDraftSavedToast() {
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
<CheckCircle2 size={16} /> Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}

View file

@ -0,0 +1,54 @@
import type { CSSProperties } from "react";
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
const COMP_DOC_INPUT_STYLE: CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};
const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
opacity: 0.6,
cursor: "default",
pointerEvents: "none",
};
type CompDocEditableRowProps = {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
placeholder?: string;
disabled?: boolean;
};
export function CompDocEditableRow({
label,
value,
onChange,
type = "text",
placeholder = "",
disabled = false,
}: CompDocEditableRowProps) {
return (
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
{label}
</div>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={disabled}
placeholder={placeholder}
className={COMP_DOC_INPUT_CLS}
style={{
...COMP_DOC_INPUT_STYLE,
...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
}}
/>
</div>
);
}

View file

@ -0,0 +1,8 @@
export const COMP_DOC_FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;

View file

@ -0,0 +1,25 @@
import { Ban } from "lucide-react";
export function CompDocNotRequiredStamp() {
return (
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
<div
className="inline-flex items-center gap-2"
style={{
marginTop: "30%",
transform: "rotate(-15deg)",
border: "3px solid var(--warning)",
color: "var(--warning)",
borderRadius: 8,
padding: "8px 18px",
fontWeight: 800,
fontSize: 22,
letterSpacing: "0.06em",
background: "rgba(255,255,255,0.65)",
}}
>
<Ban size={20} /> NOT REQUIRED
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
type CompDocNotesFieldProps = {
notes: string;
onNotesChange: (value: string) => void;
isGenerated: boolean;
};
export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
return (
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { CheckCircle2, FileText } from "lucide-react";
type CompDocPreviewBadgeProps = {
isGenerated: boolean;
};
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{isGenerated ? (
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
) : (
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
</span>
</div>
);
}

View file

@ -0,0 +1,36 @@
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { CompDocCoLine } from "./comp-doc-co-line";
export function CompDocPreviewHeader() {
return (
<>
<div
className="flex flex-wrap items-start justify-between gap-y-3"
style={{ padding: "20px 0 12px" }}
>
<div className="flex items-center">
<img
src="/seahaven-logo.svg"
alt="Seahaven Industries"
style={{ height: 34, width: "auto", display: "block" }}
/>
</div>
<div className="space-y-1">
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
</div>
</div>
<div
style={{
height: 3,
borderRadius: 2,
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
}}
/>
</>
);
}

View file

@ -0,0 +1,87 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
notes: string;
};
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { CheckCircle2 } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocPreviewSummaryProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
generatedAt: string;
isGenerated: boolean;
};
export function CompDocPreviewSummary({
row,
site,
generatedAt,
isGenerated,
}: CompDocPreviewSummaryProps) {
return (
<>
{isGenerated && (
<div
className="inline-flex items-center gap-1.5"
style={{
marginTop: 16,
padding: "4px 10px",
borderRadius: 9999,
background: "var(--doc-yes-bg)",
color: "var(--color-success)",
fontSize: 11.5,
fontWeight: 600,
}}
>
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
</div>
)}
<Text
as="h2"
variant="heading"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
family="mono"
variant="body"
sx={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
variant="body"
sx={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
</>
);
}

View file

@ -0,0 +1,89 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreview({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{isNotRequired && <CompDocNotRequiredStamp />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocProcedureListProps = {
proc: Procedure;
};
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => {
const subSteps = st.sub ?? [];
return (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{subSteps.length > 0 && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{subSteps.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
))}
</ol>
)}
</li>
);
})}
</ol>
);
}

View file

@ -0,0 +1,60 @@
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDetailsSection } from "./comp-doc-details-section";
import { CompDocNotesField } from "./comp-doc-notes-field";
import { CompDocStatusCard } from "./comp-doc-status-card";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
docState: DocState;
generatedAt: string;
notes: string;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
onNotesChange: (value: string) => void;
onDateCompletedChange: (value: string) => void;
};
export function CompDocSidebar({
row,
site,
docState,
generatedAt,
notes,
editAddress,
onAddressChange,
editDateCompleted,
amazonRep,
seaRep,
isGenerated,
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
return (
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
<CompDocDetailsSection
row={row}
site={site}
editAddress={editAddress}
onAddressChange={onAddressChange}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
/>
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
type CompDocStatusCardProps = {
docState: DocState;
generatedAt: string;
};
export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
const cfg = DOC_STATE_CFG[docState];
const StateIcon = cfg.icon;
return (
<div
className="flex items-start gap-3"
style={{
padding: 14,
borderRadius: 10,
background: cfg.cardBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<div
className="flex shrink-0 items-center justify-center"
style={{
width: 34,
height: 34,
borderRadius: 8,
background: cfg.cardIconBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<StateIcon size={17} style={{ color: cfg.iconColor }} />
</div>
<div className="min-w-0">
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{cfg.cardTitle}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
color: "var(--muted-foreground)",
marginTop: 3,
}}
>
{docStatusCardDescription(docState, generatedAt)}
</div>
</div>
</div>
);
}

View file

@ -1,82 +1,178 @@
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
type UpliftAction = "approve" | "reject" | "request-changes";
type DispatchUpliftsSectionProps = {
uplifts: UpliftRequest[];
isLocked: boolean;
onApprove: (uplift: UpliftRequest) => void;
onDeny: (uplift: UpliftRequest) => void;
onReject: (uplift: UpliftRequest) => void;
onRequestChanges: (uplift: UpliftRequest) => void;
};
const ACTION_LABELS: Record<Exclude<UpliftAction, "approve">, string> = {
reject: "Reject",
"request-changes": "Request Changes",
};
function actionColor(action: UpliftAction): "success" | "error" | "primary" {
if (action === "approve") return "success";
if (action === "reject") return "error";
return "primary";
}
function actionVariant(action: UpliftAction): "contained" | "outlined" {
return action === "request-changes" ? "outlined" : "contained";
}
type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
uplift: UpliftRequest;
};
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) {
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
type UpliftCardActionsProps = Pick<
UpliftCardProps,
"isLocked" | "onApprove" | "onReject" | "onRequestChanges"
> & {
uplift: UpliftRequest;
};
function downloadUpliftEvidence(uplift: UpliftRequest) {
return upliftsApi
.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence")
.catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
});
}
function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) {
const evidenceDocumentId = uplift.evidenceDocumentId;
return (
<Box className="rounded border border-border p-3">
<>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Chip label={uplift.status} size="small" />
{uplift.requiredTier === 2 && (
<Typography variant="caption">Tier 2 · Manager approval</Typography>
)}
<Typography variant="body2">
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "}
(+${Number(uplift.delta).toFixed(2)})
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}
</Typography>
</Stack>
{Boolean(uplift.expiresAt) && (
<Typography variant="caption" color="text.secondary" className="mt-1 block">
Expires {formatDateTime(uplift.expiresAt)}
</Typography>
)}
{Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{evidenceDocumentId != null && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }} className="mt-1">
<Typography variant="body2">
<strong>Evidence:</strong>{" "}
{uplift.evidenceFileName || `Document #${evidenceDocumentId}`}
</Typography>
<Button size="small" variant="text" onClick={() => void downloadUpliftEvidence(uplift)}>
Download
</Button>
</Stack>
)}
{uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && (
<Typography variant="body2" color="error" className="mt-1">
Notification error: {uplift.notificationError}
</Typography>
)}
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography variant="body2" color="text.secondary" className="mt-1">
Requested by {uplift.requestedByVendorName || "vendor"}
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
</Typography>
)}
{uplift.status !== "Pending" &&
Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
<Box className="mt-1">
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" color="text.secondary">
{uplift.status} by {uplift.decidedByName || "—"}
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
)}
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
)}
</Box>
)}
{uplift.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
{(["approve", "deny"] as const).map((action) => (
<Tooltip
key={action}
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
>
<span>
<Button
size="small"
color={action === "approve" ? "success" : "error"}
variant="contained"
disabled={!canDecide}
onClick={() => (action === "approve" ? onApprove(uplift) : onDeny(uplift))}
>
{action === "approve" ? "Approve" : "Deny"}
</Button>
</span>
</Tooltip>
))}
</Stack>
</>
);
}
function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
if (uplift.status === "Pending") return null;
if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
return (
<Box className="mt-1">
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" color="text.secondary">
{uplift.status} by {uplift.decidedByName || "—"}
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
)}
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
)}
</Box>
);
}
function UpliftCardActions({
uplift,
isLocked,
onApprove,
onReject,
onRequestChanges,
}: UpliftCardActionsProps) {
if (uplift.status !== "Pending") return null;
const canDecide = uplift.canDecide && !isLocked;
const decisionActions: Exclude<UpliftAction, "approve">[] = ["reject", "request-changes"];
return (
<Stack direction="row" spacing={1} className="mt-2">
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
<span>
<Button
size="small"
color="success"
variant="contained"
disabled={!canDecide}
onClick={() => onApprove(uplift)}
>
Approve
</Button>
</span>
</Tooltip>
{decisionActions.map((action) => (
<Tooltip key={action} title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
<span>
<Button
size="small"
color={actionColor(action)}
variant={actionVariant(action)}
disabled={!canDecide}
onClick={() => (action === "reject" ? onReject(uplift) : onRequestChanges(uplift))}
>
{ACTION_LABELS[action]}
</Button>
</span>
</Tooltip>
))}
</Stack>
);
}
function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) {
return (
<Box className="rounded border border-border p-3">
<UpliftCardHeader uplift={uplift} />
<UpliftCardDecisionSummary uplift={uplift} />
<UpliftCardActions
uplift={uplift}
isLocked={isLocked}
onApprove={onApprove}
onReject={onReject}
onRequestChanges={onRequestChanges}
/>
</Box>
);
}
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
uplifts,
isLocked,
onApprove,
onDeny,
onReject,
onRequestChanges,
}: DispatchUpliftsSectionProps) {
return (
<Box>
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
uplift={uplift}
isLocked={isLocked}
onApprove={onApprove}
onDeny={onDeny}
onReject={onReject}
onRequestChanges={onRequestChanges}
/>
))}
</Stack>

View file

@ -99,7 +99,10 @@ export function DispatchDetailModal({
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
onRequestChanges={(uplift) =>
controller.setUpliftAction({ type: "request-changes", uplift })
}
/>
<DispatchVendorCommunication
@ -151,7 +154,9 @@ export function DispatchDetailModal({
onClose={() => controller.setUpliftAction(null)}
onConfirm={controller.handleUpliftConfirm}
isPending={
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending
}
/>
)}

View file

@ -11,7 +11,7 @@ import {
type UpliftDecisionDialogProps = {
open: boolean;
type: "approve" | "deny";
type: "approve" | "reject" | "request-changes";
requestedNTE: number;
currentNTE: number | null;
onClose: () => void;
@ -19,6 +19,13 @@ type UpliftDecisionDialogProps = {
isPending?: boolean;
};
function titleFor(type: UpliftDecisionDialogProps["type"], requestedNTE: number): string {
if (type === "approve") {
return `Approve uplift to $${requestedNTE.toFixed(2)}?`;
}
return type === "reject" ? "Reject Uplift" : "Request Changes";
}
export function UpliftDecisionDialog({
open,
type,
@ -29,6 +36,7 @@ export function UpliftDecisionDialog({
isPending = false,
}: UpliftDecisionDialogProps) {
const [note, setNote] = useState("");
const noteRequired = type === "reject" || type === "request-changes";
const handleClose = () => {
setNote("");
@ -36,15 +44,14 @@ export function UpliftDecisionDialog({
};
const handleConfirm = () => {
if (noteRequired && !note.trim()) return;
onConfirm(note.trim());
setNote("");
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle>
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
<DialogContent>
{type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
@ -57,7 +64,8 @@ export function UpliftDecisionDialog({
multiline
minRows={2}
className="mt-2"
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
label={noteRequired ? "Note (required)" : "Optional note"}
required={noteRequired}
value={note}
onChange={(event) => setNote(event.target.value)}
/>
@ -66,8 +74,8 @@ export function UpliftDecisionDialog({
<Button onClick={handleClose}>Cancel</Button>
<Button
variant="contained"
color={type === "approve" ? "success" : "error"}
disabled={type === "deny" ? !note.trim() || isPending : isPending}
color={type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || isPending : isPending}
onClick={handleConfirm}
>
Confirm

View file

@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
/** Primary service (`pm`). Required before CompDoc can open. */
pm?: string;
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void;
@ -18,6 +21,7 @@ type CompDocCellProps = {
export function CompDocCell({
status,
woType,
pm = "",
mediaCount = 0,
onOpen,
onOpenMedia,
@ -43,6 +47,20 @@ export function CompDocCell({
return null;
}
if (!hasCompletionDocService(pm)) {
return (
<span
title="Select a service before opening the completion document"
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold"
style={{ color: "var(--color-text-muted)", opacity: 0.65 }}
aria-disabled="true"
>
<FileText size={15} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
Select service
</span>
);
}
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";

View file

@ -0,0 +1,56 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
type DueDateCalendarDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
onSave: (iso: string) => void;
};
/**
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
* Reuses the shared MiniCalendar used by the standard Due Date popover.
*/
export function DueDateCalendarDialog({
open,
onOpenChange,
value,
onSave,
}: DueDateCalendarDialogProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 320 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
<DialogTitle
className="mb-3 p-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
Due Date
</DialogTitle>
<div className="flex justify-center">
<MiniCalendar
value={value}
onChange={(iso) => {
onSave(iso);
onOpenChange(false);
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type DueDateCellProps = {
type: WOType | "";
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
};
/**
* Board / slide-over Due Date editor.
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
*/
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
</>
);
}

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
/**
* Board rows often have tech/company but no vendorId — resolve via vendors list.
* Prefers a stable vendor id over name+company matching.
*/
export function resolveTechnicianKey(
vendorId: string | number,
tech: string,
company: string,
vendors: VendorDropdownItem[],
): string {
const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : "";
if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) {
return idStr;
}
if (idStr && vendors.length === 0) {
return idStr;
}
const name = tech.trim();
if (!name) {
return idStr;
}
const byNameAndCompany =
vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ??
vendors.find((vendor) => vendor.contactName === name);
if (byNameAndCompany) {
return String(byNameAndCompany.id);
}
// Orphan tech string used as option key when no vendor row matches (legacy board cells).
return idStr || name;
}

View file

@ -0,0 +1,53 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
type ResolveVendorCompanyAnchorInput = {
company: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
vendors: VendorDropdownItem[];
};
export function resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors,
}: ResolveVendorCompanyAnchorInput): {
companyName: string;
companyVendor: VendorDropdownItem | null;
} {
const companyName = resolvedCompany || company;
if (!companyName) {
return { companyName: "", companyVendor: null };
}
const companyVendor =
selectedVendor ??
vendors.find((vendor) => vendor.companyName === companyName) ??
({
id: "",
companyName,
contactName: "",
tradeSpecialties: "",
address: "",
distanceMiles: null,
} satisfies VendorDropdownItem);
return { companyName, companyVendor };
}
export function mergeVendorDropdownItems(
vendors: VendorDropdownItem[],
addedVendors: VendorDropdownItem[],
): VendorDropdownItem[] {
const byId = new Map<string, VendorDropdownItem>();
for (const vendor of vendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
for (const vendor of addedVendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
return Array.from(byId.values());
}

View file

@ -49,7 +49,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
title="This WO missed its deadline. Update the Due Date to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@ -83,8 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. A new date must be scheduled before the status can be
updated.
This work order is past due. Update the Due Date before the status can be updated.
</Text>
<button
@ -98,7 +97,7 @@ export function StatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
Update Due Date →
</button>
</div>
</div>

View file

@ -0,0 +1,75 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
pendingCreates: Record<string, VendorFormValues>;
selectedKey: string;
selectedVendor: VendorDropdownItem | undefined;
resolvedCompany: string;
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
};
export function useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
}: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
allVendors.find(
(vendor) =>
vendor.contactName === tech &&
vendor.companyName === (resolvedCompany || anchorCompanyName),
) ??
selectedVendor;
const handleSave = async () => {
setSaveError(null);
let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);
return;
}
}
onSave({
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: saveVendor?.contactName ?? tech,
techPhone: phone,
});
onOpenChange(false);
};
return { handleSave };
}

View file

@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = {
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -13,7 +14,15 @@ type VendorCellProps = {
onSave: (patch: VendorPatch) => void;
};
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
export function VendorCell({
vendorId = "",
company,
tech,
techPhone,
vendors,
q,
onSave,
}: VendorCellProps) {
const [open, setOpen] = useState(false);
const hasAny = !!(tech || company);
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<VendorDialog
open={open}
onOpenChange={setOpen}
vendorId={vendorId}
company={company}
tech={tech}
techPhone={techPhone}

View file

@ -0,0 +1,134 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type VendorDialogAddTechnicianProps = {
companyName: string;
companyVendor: VendorDropdownItem;
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
};
export function VendorDialogAddTechnician({
companyName,
companyVendor,
onCreated,
}: VendorDialogAddTechnicianProps) {
const [expanded, setExpanded] = useState(false);
const [contactName, setContactName] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setContactName("");
setPhone("");
setError(null);
};
const handleAdd = () => {
const payload = buildTechnicianCreatePayload({
companyVendor,
contactName,
phone,
});
const parsed = vendorSchema.safeParse(payload);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
const message =
fieldErrors.contactName?.[0] ??
fieldErrors.phone?.[0] ??
fieldErrors.companyName?.[0] ??
"Enter a valid technician name";
setError(message);
return;
}
setError(null);
const draftItem = createDraftVendorDropdownItem(parsed.data);
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
resetForm();
setExpanded(false);
};
if (!expanded) {
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--color-primary)" }}
>
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
</button>
);
}
return (
<div
className="space-y-2 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
New technician for {companyName}
</Text>
<div>
<FieldLabel>Technician Name</FieldLabel>
<input
autoFocus
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Full name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(000) 000-0000"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(error) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{error}
</Text>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => {
resetForm();
setExpanded(false);
}}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={contactName.trim() === ""}
onClick={handleAdd}
>
Add
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { Wrench } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
type VendorDialogBodyProps = {
selectedKey: string;
technicianOptions: TechnicianOption[];
anchorCompanyName: string;
companyVendorForAdd: VendorDropdownItem | undefined;
tech: string;
phone: string;
notes: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
saveError: string | null;
onPick: (key: string) => void;
onPhoneChange: (phone: string) => void;
onNotesChange: (notes: string) => void;
onTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => void;
};
export function VendorDialogBody({
selectedKey,
technicianOptions,
anchorCompanyName,
companyVendorForAdd,
tech,
phone,
notes,
resolvedCompany,
selectedVendor,
saveError,
onPick,
onPhoneChange,
onNotesChange,
onTechnicianCreated,
}: VendorDialogBodyProps) {
return (
<>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={onPick}
/>
{companyVendorForAdd != null && (
<div className="mt-2">
<VendorDialogAddTechnician
companyName={anchorCompanyName}
companyVendor={companyVendorForAdd}
onCreated={onTechnicianCreated}
/>
</div>
)}
</div>
{Boolean(tech) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => onPhoneChange(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
{Boolean(saveError) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{saveError}
</Text>
)}
</>
);
}

View file

@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
type VendorDialogFooterProps = {
isPending: boolean;
onCancel: () => void;
onSave: () => void;
};
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
disabled={isPending}
onClick={onCancel}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={isPending}
onClick={() => void onSave()}
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
);
}

View file

@ -0,0 +1,6 @@
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};

View file

@ -1,32 +1,30 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -37,43 +35,94 @@ type VendorDialogProps = {
export function VendorDialog({
open,
onOpenChange,
vendorId = "",
company,
tech,
techPhone = "",
vendors,
onSave,
}: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
}, [open, tech, techPhone, technicianOptions]);
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected =
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
technicianOptions.find((o) => o.name === t || o.key === t);
const selectedKey = selected?.key ?? "";
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
const name = option?.name ?? key;
setT(name);
setPhone(phoneForOption(name, technicianOptions));
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
};
const handleTechnicianCreated = (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
};
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
});
return (
<Dialog
open={open}
@ -96,84 +145,28 @@ export function VendorDialog({
Search and select the technician
</Text>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={handlePick}
/>
</div>
<VendorDialogBody
selectedKey={selectedKey}
technicianOptions={technicianOptions}
anchorCompanyName={anchorCompanyName}
companyVendorForAdd={companyVendor ?? undefined}
tech={selected?.name ?? t}
phone={phone}
notes={notes}
resolvedCompany={resolvedCompany}
selectedVendor={selectedVendor}
saveError={saveError}
onPick={handlePick}
onPhoneChange={setPhone}
onNotesChange={setNotes}
onTechnicianCreated={handleTechnicianCreated}
/>
{Boolean(t) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(t) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
vendorId: selectedVendor ? String(selectedVendor.id) : "",
company: resolvedCompany,
tech: t,
techPhone: phone,
});
onOpenChange(false);
}}
>
Save
</Button>
</div>
<VendorDialogFooter
isPending={createVendor.isPending}
onCancel={() => onOpenChange(false)}
onSave={handleSave}
/>
</DialogContent>
</Dialog>
);

View file

@ -8,6 +8,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
@ -100,7 +101,11 @@ export function WoTableInlineRowCells({
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
<DueDateCell
type={draft.type}
value={draft.dueDate}
onSave={(date) => onPatch({ dueDate: date })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
@ -123,6 +128,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}

View file

@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace,
search,
forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle,
handlers,
onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
highlight={forceScheduleHighlight}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
<DueDateCell
type={row.type}
value={row.dueDate}
q={search}
highlight={forceDueDateHighlight || row.isPastDue}
onSave={(date) => onPatchRow({ dueDate: date })}
/>
</td>
</>
);

View file

@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell";
@ -12,7 +13,7 @@ type WoTableRowServiceCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
search?: string;
mutationCellStyle?: React.CSSProperties;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
};
@ -56,6 +57,7 @@ export function WoTableRowServiceCells({
onClick={(e) => e.stopPropagation()}
>
<VendorCell
vendorId={row.vendorId}
company={row.company}
tech={row.tech}
techPhone={row.techPhone}
@ -94,6 +96,7 @@ export function WoTableRowServiceCells({
<CompDocCell
status={row.docStatus}
woType={row.type}
pm={row.pm}
mediaCount={row.mediaCount}
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}

View file

@ -45,6 +45,7 @@ type WoTableRowProps = {
pmTypes: string[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: DragVizState;
@ -61,6 +62,7 @@ export function WoTableRow({
pmTypes,
search,
forceScheduleHighlight,
forceDueDateHighlight,
isSelected,
isFlashing,
dragViz,
@ -154,6 +156,7 @@ export function WoTableRow({
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={mutationCellStyle}
handlers={handlers}
onPatchRow={patchRow}

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters,
onPatch,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc,
onOpenMedia,
onPatch,
onScheduleHighlight: setScheduleHighlightId,
onScheduleHighlight: setDueDateHighlightId,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}

View file

@ -0,0 +1,90 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
wizardOpen: boolean;
onWizardOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
sites: LocationOption[];
pmTypes: string[];
vendorTechs: string[];
advOpen: boolean;
onAdvOpenChange: (open: boolean) => void;
advInitial: WorkOrderAdvancedFilters;
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
onClearAdvanced: () => void;
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
};
export function WorkOrdersListOverlays({
wizardOpen,
onWizardOpenChange,
users,
sites,
pmTypes,
vendorTechs,
advOpen,
onAdvOpenChange,
advInitial,
onApplyAdvanced,
onClearAdvanced,
activeDocRow,
onDocOpenChange,
completionDocs,
onSaveCompDoc,
onDateCompletedChange,
activeMediaRow,
onMediaOpenChange,
}: WorkOrdersListOverlaysProps) {
return (
<>
<WorkOrdersAdvancedSearchSheet
open={advOpen}
onOpenChange={onAdvOpenChange}
initial={advInitial}
onApply={onApplyAdvanced}
onClearAll={onClearAdvanced}
sites={sites}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={onMediaOpenChange}
/>
)}
</>
);
}

View file

@ -1,32 +1,65 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { LoadingRegion } from "@/components/ui/loading-region";
import { Text } from "@/components/ui/text";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { guardDialogClose } from "@/lib/dialog-guard";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploadModal({
row,
open,
onOpenChange,
uploads,
onChange,
}: MediaUploadModalProps) {
function resolveWorkOrderVersion(
detailRowVersion: string | null | undefined,
rowVersion: string | null | undefined,
): string | null {
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
return version || null;
}
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
detail,
(input) => uploadMutation.mutateAsync(input),
);
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
const requireVersion = (): string | null => {
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return null;
}
return version;
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -45,11 +78,42 @@ export function MediaUploadModal({
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
Upload photos and videos for this work order, then assign Before, After, or Extra.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
{isLoading && !detail ? (
<LoadingRegion label="Loading media">
<Stack spacing={1.5}>
<Skeleton variant="rounded" height={100} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
</Stack>
</LoadingRegion>
) : (
<MediaUploader
uploads={uploads}
onUpload={startUploads}
onRemove={(id) => {
if (isLocal(id)) {
removeLocal(id);
return;
}
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
deleteMutation.mutate({ mediaId: id, workOrderVersion });
}}
onCategorize={(id, category) => {
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
}}
onRetry={retryUpload}
onCancel={removeLocal}
readOnly={isReadOnly}
pending={pending}
/>
)}
</div>
<div className="mt-2 flex justify-end">
@ -57,6 +121,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Close

View file

@ -0,0 +1,60 @@
import type { RefObject } from "react";
import { Upload } from "lucide-react";
import {
formatMediaSizeLimit,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
pending: boolean;
onFilesSelected: (files: FileList | null) => void;
};
export function MediaUploaderDropzone({
inputRef,
pending,
onFilesSelected,
}: MediaUploaderDropzoneProps) {
return (
<>
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
upload
</span>
</button>
<input
ref={inputRef}
type="file"
accept={workOrderMediaAcceptAttribute()}
multiple
hidden
disabled={pending}
onChange={(e) => {
onFilesSelected(e.target.files);
e.target.value = "";
}}
/>
</>
);
}

View file

@ -0,0 +1,113 @@
import { RotateCcw, X } from "lucide-react";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
type MediaUploaderGridItemChromeProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGridItemChrome({
file,
readOnly,
pending,
onRemove,
onRetry,
onCancel,
}: MediaUploaderGridItemChromeProps) {
const uploading = file.uploadStatus === "uploading";
const failed = file.uploadStatus === "failed";
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
return (
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
<MediaUploaderGridItemPreview file={file} />
{Boolean(uploading) && (
<div
className="absolute inset-x-0 bottom-0"
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
>
<div
style={{
height: "100%",
width: `${file.progress ?? 0}%`,
background: "var(--color-brand-primary, #2563eb)",
}}
/>
</div>
)}
{Boolean(uploading && onCancel) && (
<button
type="button"
onClick={() => onCancel?.(file.id)}
title="Cancel upload"
className="absolute flex items-center justify-center"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
{Boolean(failed && onRetry) && (
<button
type="button"
onClick={() => onRetry?.(file.id)}
title="Retry upload"
className="absolute flex items-center justify-center"
style={{
top: 4,
left: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<RotateCcw size={11} />
</button>
)}
{Boolean(showDelete && !uploading) && (
<button
type="button"
onClick={() => onRemove(file.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
type MediaUploaderGridItemMetaProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
export function MediaUploaderGridItemMeta({
file,
readOnly,
pending,
onCategorize,
}: MediaUploaderGridItemMetaProps) {
const failed = file.uploadStatus === "failed";
const needsCategory =
!readOnly &&
!file.isLegacy &&
file.uploadStatus !== "uploading" &&
file.uploadStatus !== "failed" &&
Boolean(onCategorize);
return (
<div className="space-y-1 px-1 py-1">
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.name}
</div>
{Boolean(failed) && (
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
{file.errorMessage || "Upload failed"}
</div>
)}
{needsCategory ? (
<select
aria-label={`Category for ${file.name}`}
value={file.purpose ?? ""}
disabled={pending}
onChange={(event) => {
const value = event.target.value as UploadableMediaCategory;
if (value) onCategorize?.(file.id, value);
}}
className="w-full rounded border px-1 py-0.5 text-[10px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
<option value="">Assign purpose…</option>
{PURPOSE_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
) : (
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.purpose || (file.isLegacy ? "Legacy" : "")}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type MediaUploaderGridItemPreviewProps = {
file: MediaFile;
};
export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
const TypeIcon = FILETYPE_ICON[file.fileType];
if (file.fileType === "image") {
return (
<img
src={file.url}
alt={file.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
);
}
if (file.fileType === "video") {
return (
<>
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
);
}
return (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
);
}

View file

@ -0,0 +1,46 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
type MediaUploaderGridItemProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGridItem({
file,
readOnly,
pending,
onRemove,
onCategorize,
onRetry,
onCancel,
}: MediaUploaderGridItemProps) {
return (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<MediaUploaderGridItemChrome
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
onRetry={onRetry}
onCancel={onCancel}
/>
<MediaUploaderGridItemMeta
file={file}
readOnly={readOnly}
pending={pending}
onCategorize={onCategorize}
/>
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
type MediaUploaderGridProps = {
uploads: MediaFile[];
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGrid({
uploads,
readOnly,
pending,
onRemove,
onCategorize,
onRetry,
onCancel,
}: MediaUploaderGridProps) {
if (uploads.length === 0) return null;
return (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((file) => (
<MediaUploaderGridItem
key={file.id}
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
onCategorize={onCategorize}
onRetry={onRetry}
onCancel={onCancel}
/>
))}
</div>
);
}

View file

@ -1,143 +1,129 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
Upload,
X,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
export type MediaUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
onProgress: (percent: number) => void;
};
type MediaUploaderProps = {
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
/** Upload-then-categorize: files only — category assigned after persist. */
onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (localId: string, signal: AbortSignal) => void;
onCancel?: (localId: string) => void;
/** Legacy local-only mode (table modal fallback). */
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
};
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
export function MediaUploader({
uploads,
onUpload,
onRemove,
onCategorize,
onRetry,
onCancel,
onChange,
readOnly = false,
pending = false,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
if (readOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length === 0) return;
if (onUpload) {
const nextControllers: Record<string, AbortController> = { ...controllers };
const requests: MediaUploadRequest[] = accepted.map((file) => {
const localId = `local-${crypto.randomUUID()}`;
const controller = new AbortController();
nextControllers[localId] = controller;
return {
localId,
file,
signal: controller.signal,
onProgress: () => undefined,
};
});
setControllers(nextControllers);
onUpload(requests);
return;
}
if (onChange) {
onChange([...uploads, ...accepted.map(makeMediaFile)]);
}
};
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
const remove = (id: string) => {
if (readOnly || pending) return;
const target = uploads.find((u) => u.id === id);
if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
if (onRemove) {
onRemove(id);
return;
}
onChange?.(uploads.filter((u) => u.id !== id));
};
const cancel = (id: string) => {
controllers[id]?.abort();
onCancel?.(id);
};
const retry = (id: string) => {
controllers[id]?.abort();
const controller = new AbortController();
setControllers((prev) => ({ ...prev, [id]: controller }));
onRetry?.(id, controller.signal);
};
return (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Drag files here or click to browse
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => addFiles(e.target.files)}
/>
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
{!readOnly && (
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
)}
<MediaUploaderGrid
uploads={uploads}
readOnly={readOnly}
pending={pending}
onRemove={remove}
onCategorize={onCategorize}
onRetry={onRetry ? retry : undefined}
onCancel={cancel}
/>
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useMemo, useState } from "react";
import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import type {
WorkOrderAuditEntry,
WorkOrderComment,
} from "@/domain/work-orders/types/work-order-detail";
import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline";
import { Text } from "@/components/ui/text";
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
type MentionUser = { id: string | number; name: string };
type WorkOrderActivityPanelProps = {
customerComments: WorkOrderComment[];
internalComments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
mentionUsers: MentionUser[];
isSubmitting: boolean;
onSubmitComment: (text: string) => void | Promise<void>;
};
export function WorkOrderActivityPanel({
customerComments,
internalComments,
auditLog,
mentionUsers,
isSubmitting,
onSubmitComment,
}: WorkOrderActivityPanelProps) {
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const customerTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(customerComments),
[customerComments],
);
const internalTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(internalComments),
[internalComments],
);
return (
<Paper variant="outlined" className="p-4">
<Text as="p" variant="heading" className="mb-3 font-semibold">
Activity
</Text>
<Tabs
value={activeTab}
onChange={(_event, value: WorkOrderActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && (
<CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
)}
{activeTab === "internal" && (
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
)}
{activeTab === "audit" && <WorkOrderAuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box
component="form"
onSubmit={(event) => {
event.preventDefault();
const trimmed = commentText.trim();
if (!trimmed) {
return;
}
void (async () => {
try {
await onSubmitComment(trimmed);
setCommentText("");
} catch {
// Mutation toast handles failure; keep the draft for retry.
}
})();
}}
className="mt-4 flex flex-col gap-2"
>
<CommentMentionComposer
value={commentText}
onChange={setCommentText}
candidates={mentionUsers}
disabled={isSubmitting}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={isSubmitting || !commentText.trim()}
>
{isSubmitting ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
);
}

View file

@ -0,0 +1,38 @@
import { useMemo } from "react";
import { Box, Stack, Typography } from "@mui/material";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
export function WorkOrderAuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return <Typography sx={{ color: "text.secondary" }}>No changes recorded.</Typography>;
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Stack>
);
}

View file

@ -0,0 +1,12 @@
import { Box, Typography } from "@mui/material";
export function WorkOrderDetailField({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Box>
);
}

View file

@ -0,0 +1,113 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
type WorkOrderDispatchesAccordionProps = {
wo: WorkOrderDetail;
onOpenDispatchDetail: (dispatchId: string | number) => void;
onOpenVendorEditor: (
event: React.MouseEvent,
vendorId: string | number,
vendorName: string,
) => void;
};
export function WorkOrderDispatchesAccordion({
wo,
onOpenDispatchDetail,
onOpenVendorEditor,
}: WorkOrderDispatchesAccordionProps) {
return (
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const waitColor = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => onOpenDispatchDetail(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography
variant="caption"
className={
waitColor === "green"
? "text-green-600"
: waitColor === "yellow"
? "text-yellow-600"
: "text-red-600"
}
>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
void onOpenVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography sx={{ color: "text.secondary" }}>No dispatches yet.</Typography>
)}
</AccordionDetails>
</Accordion>
);
}

View file

@ -0,0 +1,114 @@
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { VendorEditorState } from "./work-order-vendor-editor-types";
import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
type WorkOrderVendorEditorDialogProps = {
editor: VendorEditorState | null;
loading: boolean;
error: string;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onEditorChange: (
updater: (current: VendorEditorState | null) => VendorEditorState | null,
) => void;
};
export function WorkOrderVendorEditorDialog({
editor,
loading,
error,
isSaving,
onClose,
onSave,
onEditorChange,
}: WorkOrderVendorEditorDialogProps) {
return (
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
{editor?.vendorName}
</Typography>
{Boolean(error) && <Alert severity="error">{error}</Alert>}
{loading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />
</Box>
) : (
<>
<TextField
label="Technician name"
value={editor?.contactName ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
label="Phone"
value={editor?.phone ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
)
}
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
helperText="Use (XXX) XXX-XXXX"
/>
<TextField
label="Email"
type="email"
value={editor?.email ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={editor?.notes ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,8 @@
export interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
phone: string;
email: string;
notes: string;
}

View file

@ -0,0 +1,16 @@
import type { VendorEditorState } from "./work-order-vendor-editor-types";
export function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
export function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}

View file

@ -0,0 +1,66 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
import { formatDateTime } from "@/lib/time-utils";
export function WorkOrderViewAccordions({ wo }: { wo: WorkOrderDetail }) {
return (
<>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<WorkOrderDetailField label="Problem" value={wo.problem || "—"} />
<WorkOrderDetailField label="Trade" value={wo.trade || "—"} />
<WorkOrderDetailField label="Sub-Trade" value={wo.subTrade || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<WorkOrderDetailField
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<WorkOrderDetailField label="Due Date" value={wo.dueDate || "—"} />
<WorkOrderDetailField label="Scheduled" value={wo.scheduledDate || "—"} />
<WorkOrderDetailField label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<WorkOrderDetailField
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<WorkOrderDetailField label="Customer PO" value={wo.po || "—"} />
<WorkOrderDetailField label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
</>
);
}

View file

@ -0,0 +1,73 @@
import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field";
import { Text } from "@/components/ui/text";
type WorkOrderViewDetailsProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
assignAnchor: HTMLElement | null;
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
onAssign: (userId: string | number, userName: string) => void;
onUnassign: () => void;
};
export function WorkOrderViewDetails({
wo,
users,
assignAnchor,
onAssignAnchorChange,
onAssign,
onUnassign,
}: WorkOrderViewDetailsProps) {
return (
<Paper variant="outlined" className="p-4">
<Text as="p" variant="heading" className="mb-3 font-semibold">
Details
</Text>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<WorkOrderDetailField label="Internal WO" value={wo.internalWONumber} />
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
Assigned To
</Typography>
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => onAssignAnchorChange(null)}
>
<MenuItem
onClick={() => {
onAssignAnchorChange(null);
onUnassign();
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
onAssignAnchorChange(null);
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
);
}

View file

@ -0,0 +1,95 @@
import { Link as RouterLink, useNavigate } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
const STATUS_COLORS: Record<
string,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
Open: "primary",
"In Progress": "info",
"On Hold": "warning",
Done: "success",
Cancelled: "default",
};
type WorkOrderViewHeaderProps = {
wo: WorkOrderDetail;
workOrderId: string;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: string) => void;
onOpenDispatch: () => void;
};
export function WorkOrderViewHeader({
wo,
workOrderId,
statusAnchor,
onStatusAnchorChange,
onStatusChange,
onOpenDispatch,
}: WorkOrderViewHeaderProps) {
const navigate = useNavigate();
return (
<>
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography sx={{ color: "text.primary" }}>{wo.internalWONumber}</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", md: "flex-start" } }}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => onStatusAnchorChange(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={onOpenDispatch}>
Dispatch
</Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
Edit
</Button>
<Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List
</Button>
</Stack>
</Stack>
</>
);
}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
type WizardDueDateFieldProps = {
type: WOType | "";
value: string;
placeholder: string;
onChange: (iso: string) => void;
};
/**
* Wizard Due Date editor.
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
*/
export function WizardDueDateField({
type,
value,
placeholder,
onChange,
}: WizardDueDateFieldProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
</>
);
}

View file

@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
>
Due Date
</WizLabelRow>
<WizardDateField
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}

View file

@ -0,0 +1,170 @@
import type { QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { Location } from "@/domain/locations/types/location";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import { queryKeys } from "@/infra/query-key/query-key";
type CompDocSite = { street: string; fullAddress: string };
export function buildCompDocAddressLine(site: CompDocSite): string {
if (site.street.trim()) return site.street.trim();
return site.fullAddress !== "—" ? site.fullAddress : "";
}
function locationUpdatePayload(location: Location, address: string): Location {
return {
...location,
address,
};
}
type CreateCompDocDialogActionsParams = {
row: WorkOrderTableRow;
notes: string;
generatedAt: string;
editDateCompleted: string;
editAddress: string;
docState: DocState;
docFields: DocFields;
site: CompDocSite;
locId: string;
isMockLocation: boolean;
locationDetail: Location | undefined;
queryClient: QueryClient;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
setDocState: (state: DocState) => void;
setGeneratedAt: (value: string) => void;
};
export function createCompDocDialogActions({
row,
notes,
generatedAt,
editDateCompleted,
editAddress,
docState,
docFields,
site,
locId,
isMockLocation,
locationDetail,
queryClient,
onSave,
onDateCompletedChange,
setDocState,
setGeneratedAt,
}: CreateCompDocDialogActionsParams) {
async function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus): Promise<void> {
await onSave({ notes, generatedAt, ...extra }, status);
}
async function syncCompletedDateIfChanged(): Promise<void> {
if (editDateCompleted !== (row.completedDate || "")) {
await onDateCompletedChange?.(editDateCompleted);
}
}
async function syncAddressIfChanged(): Promise<void> {
const next = editAddress.trim();
const current = buildCompDocAddressLine(site);
if (next === current) return;
if (isMockLocation || !locId) return;
if (!locationDetail) {
throw new Error("Location details are still loading. Try saving again.");
}
await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
function refreshAuthoritativeState() {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
if (locId && !isMockLocation) {
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
}
}
async function handleMarkNotRequired() {
const previous = docState;
setDocState("not-required");
try {
await persist({}, "NN");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(
error instanceof Error ? error.message : "Failed to mark document as not required",
);
}
}
async function handleUndo() {
const previous = docState;
setDocState("draft");
try {
await persist({}, "No");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
}
}
async function handleSaveDraft() {
try {
await syncCompletedDateIfChanged();
await syncAddressIfChanged();
await persist({}, "No");
showCompDocDraftSavedToast();
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
if (!editDateCompleted.trim()) {
toast.error("Date Work Completed is required to generate the document.");
return;
}
const ts = fmtDocNow();
try {
// Prerequisites first; only mark Generated after completedDate/address persist.
await syncCompletedDateIfChanged();
await syncAddressIfChanged();
await persist({ generatedAt: ts }, "Yes");
setGeneratedAt(ts);
setDocState("generated");
} catch (error) {
// Keep Draft UI; refresh so any partially persisted date/address is authoritative.
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
function handleDownloadPdf() {
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
return {
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
};
}

View file

@ -0,0 +1,157 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
buildCompDocAddressLine,
createCompDocDialogActions,
} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
type UseCompDocDialogParams = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
export function useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
}: UseCompDocDialogParams) {
const queryClient = useQueryClient();
const locId = String(row.locationId || "");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
const [editAddress, setEditAddress] = useState("");
const seedKeyRef = useRef<string | null>(null);
const addressSeededRef = useRef(false);
const addressTouchedRef = useRef(false);
// Seed once per open/WO — row/detail refetches must not wipe in-progress edits.
// Address waits for authoritative locationDetail (when a real location id exists)
// and still accepts that first response without overwriting later user edits.
useEffect(() => {
if (!open) {
seedKeyRef.current = null;
addressSeededRef.current = false;
addressTouchedRef.current = false;
return;
}
const key = String(row.id);
if (seedKeyRef.current !== key) {
seedKeyRef.current = key;
addressSeededRef.current = false;
addressTouchedRef.current = false;
setNotes(data?.notes ?? "");
setDocState(docStateOf(row.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(row.completedDate || "");
}
if (addressTouchedRef.current || addressSeededRef.current) {
return;
}
const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
if (awaitingLocation) {
return;
}
setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
addressSeededRef.current = true;
}, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
const handleAddressChange = (value: string) => {
addressTouchedRef.current = true;
setEditAddress(value);
};
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
const effectiveStreet = editAddress.trim() || site.street;
const docFields: DocFields = {
street: effectiveStreet,
city: site.city,
state: site.state,
zip: site.zip,
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
};
const previewSite = useMemo(
() => ({
...site,
street: effectiveStreet,
fullAddress:
[effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
effectiveStreet ||
"—",
}),
[site, effectiveStreet],
);
const actions = createCompDocDialogActions({
row,
notes,
generatedAt,
editDateCompleted,
editAddress,
docState,
docFields,
site,
locId,
isMockLocation,
locationDetail,
queryClient,
onSave,
onDateCompletedChange,
setDocState,
setGeneratedAt,
});
return {
site: previewSite,
notes,
setNotes,
docState,
generatedAt,
editAddress,
setEditAddress: handleAddressChange,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
...actions,
};
}

View file

@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import {
useApproveUplift,
useDenyUplift,
useRejectUplift,
useRequestChangesUplift,
useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
@ -68,7 +69,7 @@ export function useDispatchDetailController(
const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
type: "approve" | "reject" | "request-changes";
uplift: UpliftRequest;
} | null>(null);
@ -79,7 +80,8 @@ export function useDispatchDetailController(
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId);
const rejectUpliftMutation = useRejectUplift(dispatchId);
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
const userName =
(typeof user?.name === "string" ? user.name : undefined) ??
@ -143,18 +145,19 @@ export function useDispatchDetailController(
return;
}
const id = upliftAction.uplift.id;
if (upliftAction.type === "approve") {
approveUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
return;
}
denyUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
if (!note.trim()) {
return;
}
const mutation =
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
};
return {
@ -189,7 +192,8 @@ export function useDispatchDetailController(
addSignoffMutation,
verifyMutation,
approveUpliftMutation,
denyUpliftMutation,
rejectUpliftMutation,
requestChangesUpliftMutation,
addCommentMutation,
};
}

View file

@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
type UploadMutate = (input: {
file: File;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
function revokeBlobUrl(url: string | undefined) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
const classified = classifyUpload(request.file);
return {
id: request.localId,
name: request.file.name,
fileType: classified.fileType,
url: URL.createObjectURL(request.file),
category: classified.category,
purpose: null,
progress: 0,
uploadStatus: "uploading" as const,
sourceFile: request.file,
signal: request.signal,
};
}
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
export function useMediaUploadSession(
detail: WorkOrderBoardDetail | undefined,
uploadMutateAsync: UploadMutate,
) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
[detail?.media],
);
const uploads = useMemo(() => {
const serverIds = new Set(serverUploads.map((item) => item.id));
const pendingLocals = localUploads.filter(
(item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed",
);
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
}, [serverUploads, localUploads]);
function startUploads(requests: MediaUploadRequest[]) {
setLocalUploads((prev) => {
const next = [...prev];
for (const request of requests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
revokeBlobUrl(previous?.url);
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
});
for (const request of requests) {
void uploadMutateAsync({
file: request.file,
signal: request.signal,
onProgress: (percent) => {
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
),
);
},
})
.then(() => {
setLocalUploads((prev) => {
const dropped = prev.filter((item) => isSameAttempt(item, request));
for (const item of dropped) {
revokeBlobUrl(item.url);
}
return prev.filter((item) => !isSameAttempt(item, request));
});
})
.catch((error: Error) => {
if (error.name === "AbortError") {
setLocalUploads((prev) => {
const dropped = prev.filter((item) => isSameAttempt(item, request));
for (const item of dropped) {
revokeBlobUrl(item.url);
}
return prev.filter((item) => !isSameAttempt(item, request));
});
return;
}
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
});
}
}
function retryUpload(localId: string, signal: AbortSignal) {
const target = localUploads.find((item) => item.id === localId);
if (!target?.sourceFile) return;
startUploads([
{
localId,
file: target.sourceFile,
signal,
onProgress: () => undefined,
},
]);
}
function removeLocal(id: string) {
setLocalUploads((prev) => {
const dropped = prev.filter((item) => item.id === id);
for (const item of dropped) {
revokeBlobUrl(item.url);
}
return prev.filter((item) => item.id !== id);
});
}
function isLocal(id: string) {
return localUploads.some((item) => item.id === id);
}
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
}

View file

@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types";
import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useUpdateWorkOrderVendor,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
export function useWorkOrderViewPage(workOrderId: string) {
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const mentionUsers = useMemo(
() => users.map((user) => ({ id: user.id, name: user.name })),
[users],
);
const submitComment = async (text: string) => {
const mentions = extractMentionIds(text);
await commentMutation.mutateAsync({
text,
mentions: mentions.length > 0 ? mentions : undefined,
commentType: "internal",
});
};
const openVendorEditor = async (
event: React.MouseEvent,
vendorId: string | number,
vendorName: string,
) => {
event.stopPropagation();
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
void refetch();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
return {
wo,
isLoading,
error,
refetch,
users,
customerComments,
internalComments,
auditLog,
mentionUsers,
submitComment,
dispatchOpen,
setDispatchOpen,
dispatchDetailId,
setDispatchDetailId,
statusAnchor,
setStatusAnchor,
assignAnchor,
setAssignAnchor,
vendorEditor,
setVendorEditor,
vendorEditorLoading,
vendorEditorError,
openVendorEditor,
saveVendorEditor,
statusMutation,
assignmentMutation,
commentMutation,
vendorMutation,
};
}

View file

@ -3,13 +3,11 @@ import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
@ -18,48 +16,54 @@ import {
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
function resolveActiveRow(
selected: WorkOrderTableRow | null,
rows: WorkOrderTableRow[],
): WorkOrderTableRow | null {
if (!selected) return null;
return rows.find((row) => row.id === selected.id) ?? selected;
}
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations({
const { patchField, patchFieldAsync } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const activeDocRow = useMemo(
() => resolveActiveRow(docRow, tableData.rows),
[docRow, tableData.rows],
);
const activeMediaRow = useMemo(
() => resolveActiveRow(mediaRow, tableData.rows),
[mediaRow, tableData.rows],
);
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
@ -88,14 +92,14 @@ export default function WorkOrdersListPage() {
users={users}
usersLoading={usersLoading}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
/>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
totalCount={tableData.totalCount}
sites={locations}
users={users}
onClearChip={filters.clearAdvancedFilterChip}
@ -128,40 +132,48 @@ export default function WorkOrdersListPage() {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
onOpenCompDoc={(row) => {
if (!hasCompletionDocService(row.pm)) return;
setDocRow(row);
}}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={patchField}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
<WorkOrdersListOverlays
wizardOpen={wizardOpen}
onWizardOpenChange={setWizardOpen}
users={users}
sites={locations}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
advOpen={filters.advOpen}
onAdvOpenChange={filters.setAdvOpen}
advInitial={filters.advInitial}
onApplyAdvanced={filters.applyAdvancedFilters}
onClearAdvanced={filters.clearAdvancedFilters}
activeDocRow={activeDocRow}
onDocOpenChange={(open) => {
if (!open) setDocRow(null);
}}
completionDocs={completionDocs}
onSaveCompDoc={async (data, status) => {
if (!activeDocRow) return;
// Persist board docStatus first so local generatedAt/notes never outrun the server.
await patchFieldAsync(activeDocRow.id, { docStatus: status });
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
}}
onDateCompletedChange={async (date) => {
if (!activeDocRow) return;
await patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
activeMediaRow={activeMediaRow}
onMediaOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
}}
/>
)}
</Box>
);
}

View file

@ -8,36 +8,18 @@ import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
import {
VENDOR_PORTAL_DISPATCH_FILTERS,
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
type VendorPortalDispatchFilterKey,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import { queryKeys } from "@/infra/query-key/query-key";
const FILTERS = [
{
key: "open",
label: "Open",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status !== "Completed" &&
dispatch.status !== "Verified" &&
dispatch.status !== "Cancelled",
},
{
key: "completed",
label: "Completed",
match: (dispatch: VendorPortalDispatchSummary) =>
dispatch.status === "Completed" || dispatch.status === "Verified",
},
{
key: "cancelled",
label: "Cancelled",
match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled",
},
{ key: "all", label: "All", match: () => true },
] as const;
export default function VendorPortalDashboardPage() {
const { token } = useVendorPortal();
const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const {
data: dispatches = [],
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
enabled: Boolean(token),
});
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]);
const activeFilter = getVendorPortalDispatchFilter(filter);
const filtered = useMemo(
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
[dispatches, activeFilter],
);
return (
<>
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
Active dispatches and work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => (
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button
key={item.key}
type="button"

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { queryKeys } from "@/infra/query-key/query-key";
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [refuseOpen, setRefuseOpen] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
@ -28,6 +31,11 @@ export default function VendorPortalDispatchPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
}, [refetch, queryClient, token]);
const pollEvidenceDocument = useCallback(
(documentId: number) => vendorPortalApi.getDocumentStatus(token, dispatchId, documentId),
[token, dispatchId],
);
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
if (confirmMsg && !window.confirm(confirmMsg)) return;
setActionError("");
@ -74,7 +82,7 @@ export default function VendorPortalDispatchPage() {
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
const locked = isVendorPortalLockedStatus(data.status);
return (
<>
@ -84,6 +92,7 @@ export default function VendorPortalDispatchPage() {
locked={locked}
busy={busy}
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
onRefuse={() => setRefuseOpen(true)}
onStart={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
}
@ -105,8 +114,16 @@ export default function VendorPortalDispatchPage() {
data={data}
locked={locked}
refresh={refresh}
pollEvidenceDocument={pollEvidenceDocument}
updateLocal={updateLocal}
/>
<RefuseDispatchDialog
open={refuseOpen}
dispatchId={dispatchId}
onClose={() => setRefuseOpen(false)}
onRefused={() => setActionError("")}
/>
</>
);
}

View file

@ -4,7 +4,10 @@ import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-s
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import type {
VendorPortalDispatchDetail,
VendorPortalDocumentStatus,
} from "@/domain/vendor-portal/types/vendor-portal";
type DispatchDetailSectionsProps = {
token: string;
@ -12,6 +15,7 @@ type DispatchDetailSectionsProps = {
data: VendorPortalDispatchDetail;
locked: boolean;
refresh: () => Promise<void>;
pollEvidenceDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
updateLocal: (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => void;
@ -23,6 +27,7 @@ export function DispatchDetailSections({
data,
locked,
refresh,
pollEvidenceDocument,
updateLocal,
}: DispatchDetailSectionsProps) {
return (
@ -65,6 +70,7 @@ export function DispatchDetailSections({
upliftRequests={data.upliftRequests ?? []}
locked={locked}
onChanged={refresh}
pollDocument={pollEvidenceDocument}
/>
</div>
@ -76,6 +82,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</div>
@ -88,6 +95,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</div>

Some files were not shown because too many files have changed in this diff Show more