chore(work-orders): resolve closability conflicts with platform-polish

This commit is contained in:
arthur.bassi 2026-08-12 15:33:15 -03:00
commit 1810f1559a
195 changed files with 9617 additions and 2127 deletions

View file

@ -41,3 +41,25 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npm run verify - 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 /coverage
/test-results /test-results
/playwright-report /playwright-report
/playwright-report-visual
/blob-report /blob-report
/playwright/.cache /playwright/.cache

View file

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

View file

@ -25,17 +25,23 @@ Use TanStack's own experimental broadcast client,
wired up in a single dedicated setup module, wired up in a single dedicated setup module,
`src/lib/query/setup-query-broadcast.ts`. The module: `src/lib/query/setup-query-broadcast.ts`. The module:
- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a - Exposes `startQueryBroadcast(queryClient, userId)` and
single named channel (`seahaven-admin-query`). `stopQueryBroadcast(queryClient)`.
- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE - Keys `broadcastQueryClient` to an **account-scoped** channel
=== "test"`), so it never runs in unit tests or node-based tooling. (`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
- Is invoked once from `src/lib/query/query-client.ts` against the app's on the same origin do not share cache traffic.
singleton `QueryClient`, so every tab that loads the SPA subscribes to the - Disposes the prior subscription (the unsubscribe returned by
same channel automatically — no per-feature wiring required. `broadcastQueryClient`) and clears the `QueryClient` when the authenticated
user changes or the session ends.
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
(`import.meta.env.MODE === "test"`).
- Is started after authentication (`AuthProvider` session restore + login
success) and stopped on logout and HTTP 401 session clear — not at
`QueryClient` module load.
This piggybacks on the query cache we already have (no parallel store), uses This piggybacks on the query cache we already have (no parallel store), uses
the library that owns the `QueryClient` we already depend on, and requires the library that owns the `QueryClient` we already depend on, and keeps the
close to zero application code (~20 lines) to adopt. integration isolated to auth/session boundaries.
### Alternatives considered ### Alternatives considered
@ -44,6 +50,9 @@ close to zero application code (~20 lines) to adopt.
serialization/versioning of query keys and mutation results, and keeping serialization/versioning of query keys and mutation results, and keeping
every future mutation hook wired to broadcast. More code to own and more every future mutation hook wired to broadcast. More code to own and more
surface area for subtle cache-desync bugs. surface area for subtle cache-desync bugs.
- **Constant channel from module load** — simplest wiring, but shares one
channel across all sessions on the origin; after logout/login or multi-account
use, cached work-order/vendor data can leak into the next session. Rejected.
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem - **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
unresolved; users would need to manually refresh or wait for unresolved; users would need to manually refresh or wait for
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough `refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt.
## Consequences ## Consequences
- **Positive**: sibling tabs reflect mutations (status changes, dispatch - **Positive**: sibling tabs for the same authenticated account reflect
actions, comments, patches) without a manual refresh; the integration is mutations (status changes, dispatch actions, comments, patches) without a
isolated to one setup file and does not touch domain/query-key code. manual refresh; logout/401 tear down the broadcaster and clear sensitive
cache so the next account cannot inherit prior data.
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in - **Risk — package lifecycle**: the dependency is explicitly "experimental" in
the TanStack ecosystem; its API may change or be deprecated between minor the TanStack ecosystem; its API may change or be deprecated between minor
versions. `@tanstack/query-broadcast-client-experimental`, versions. `@tanstack/query-broadcast-client-experimental`,
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
the **exact same version** in `package.json` (no `^` range). All three the **exact same version** in `package.json` (no `^` range). All three
resolve to the identical `@tanstack/query-core` version at that pin, which resolve to the identical `@tanstack/query-core` version at that pin, which
keeps the `QueryClient` type used by `setupQueryBroadcast` structurally keeps the `QueryClient` type used by the broadcast helpers structurally
identical to the one constructed in `query-client.ts` — a caret range lets identical to the one constructed in `query-client.ts` — a caret range lets
npm resolve the broadcast client and React Query against two different npm resolve the broadcast client and React Query against two different
`query-core` versions independently, which breaks `QueryClient` type `query-core` versions independently, which breaks `QueryClient` type
identity (TS2322) even though both packages build fine in isolation. Bump identity (TS2322) even though both packages build fine in isolation. Bump
all three together and re-evaluate on every TanStack Query upgrade. all three together and re-evaluate on every TanStack Query upgrade.
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same - **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
origin; it does not sync across different users/sessions or devices. This is origin; it does not sync across different users/sessions or devices. Account
acceptable for the current requirement (single user, multiple tabs). scoping further limits sync to tabs of the same `userId`.
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental` - **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
dependency from `package.json` and delete the call to `setupQueryBroadcast` dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
itself). No other code depends on it, so rollback is a single, isolated `api.ts`. No other code depends on it, so rollback is isolated with no data
change with no data migration. migration.

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

@ -537,6 +537,20 @@ test.describe("Vendor directory prototype parity", () => {
await mockVendorApi(page, { listError: true }); await mockVendorApi(page, { listError: true });
await page.goto("/vendors"); 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( await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i, /Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 }, { timeout: 15_000 },
@ -549,4 +563,23 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click(); 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

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

View file

@ -10,6 +10,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --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({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true, fullyParallel: true,
forbidOnly: Boolean(process.env.CI), forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, 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

@ -3,13 +3,13 @@ import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths"; import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client"; import queryClient from "@/lib/query/query-client";
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void { function clearSessionAndRedirectToLogin(): void {
clearAuth(); clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") { if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login"); window.location.replace("/login");

View file

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

View file

@ -27,28 +27,319 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; 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 { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils"; 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 { function formatMoney(value: number | null | undefined): string {
return `$${Number(value ?? 0).toFixed(2)}`; 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() { export default function UpliftQueuePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState("Pending"); const [status, setStatus] = useState("Pending");
const [tier, setTier] = useState(""); const [tier, setTier] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize] = useState(25); const [pageSize] = useState(25);
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
item: UpliftQueueItem;
} | null>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const listParams = useMemo( const listParams = useMemo(
@ -58,39 +349,59 @@ export default function UpliftQueuePage() {
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift(); const approveUplift = useApproveUplift();
const denyUplift = useDenyUplift(); const rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
const rows = data?.items ?? []; const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0; 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 = () => { const handleConfirm = () => {
if (!upliftAction) return; if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const id = upliftAction.item.id;
const onDone = () => {
closeDialog();
void refetch();
};
if (upliftAction.type === "approve") { if (upliftAction.type === "approve") {
approveUplift.mutate( approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
{ id: upliftAction.item.id, note: note || undefined }, } else if (upliftAction.type === "reject") {
{ rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} else { } else {
if (!note.trim()) return; requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
denyUplift.mutate(
{ id: upliftAction.item.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} }
}; };
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 ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex flex-col gap-4 p-4">
<Stack <Stack
@ -101,177 +412,26 @@ export default function UpliftQueuePage() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<Stack <UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
direction="row" <UpliftQueueFilters
spacing={2} status={status}
sx={{ tier={tier}
alignItems: "baseline", onStatusChange={handleStatusChange}
}} onTierChange={handleTierChange}
> />
<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>
</Stack> </Stack>
{Boolean(error) && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"} {error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert> </Alert>
)} )}
<TableContainer component={Paper} variant="outlined"> <UpliftQueueTable
<Table size="small"> isLoading={isLoading}
<TableHead> rows={rows}
<TableRow> onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
<TableCell className="font-semibold">Dispatch</TableCell> onDownloadEvidence={(r) => void downloadEvidence(r)}
<TableCell className="font-semibold">PO</TableCell> onDecide={handleDecide}
<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>
<TablePagination <TablePagination
component="div" component="div"
count={totalCount} count={totalCount}
@ -280,46 +440,15 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize} rowsPerPage={pageSize}
rowsPerPageOptions={[25]} rowsPerPageOptions={[25]}
/> />
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}> <UpliftDecisionDialog
<DialogTitle> action={upliftAction}
{upliftAction?.type === "approve" note={note}
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` noteRequired={noteRequired}
: "Deny Uplift"} decisionPending={decisionPending}
</DialogTitle> onNoteChange={setNote}
<DialogContent> onClose={closeDialog}
{upliftAction?.type === "approve" && ( onConfirm={handleConfirm}
<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>
</Box> </Box>
); );
} }

View file

@ -1,90 +1,82 @@
import { useState, type ReactNode } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material";
Alert, import { toast } from "react-toastify";
Box, import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
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 type { UpliftRequest } from "@/domain/uplifts/types/uplift"; 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 { function formatMoney(value: number | null | undefined): string {
if (value == null) return "—"; if (value == null) return "—";
return `$${Number(value).toFixed(2)}`; 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() { export default function VendorPoViewPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id); const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
const approveUplift = useApproveUplift(id); const approveUplift = useApproveUplift(id);
const denyUplift = useDenyUplift(id); const rejectUplift = useRejectUplift(id);
const requestChangesUplift = useRequestChangesUplift(id);
const [upliftAction, setUpliftAction] = useState<{ const [upliftAction, setUpliftAction] = useState<VendorPoUpliftDecision | null>(null);
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const [note, setNote] = useState(""); 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 = () => { const handleUpliftConfirm = () => {
if (!upliftAction) return; if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const upliftId = upliftAction.uplift.id;
const onDone = () => {
closeUpliftDialog();
void refetch();
};
if (upliftAction.type === "approve") { if (upliftAction.type === "approve") {
approveUplift.mutate( approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone });
{ id: upliftAction.uplift.id, note: note || undefined }, } else if (upliftAction.type === "reject") {
{ rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} else { } else {
if (!note.trim()) return; requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone });
denyUplift.mutate(
{ id: upliftAction.uplift.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} }
}; };
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) { if (isLoading) {
return ( return (
<Box className="flex justify-center p-10"> <Box className="flex justify-center p-10">
@ -141,212 +133,29 @@ export default function VendorPoViewPage() {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Paper variant="outlined" className="p-4"> <VendorSummaryPaper vendor={vendor} />
<Typography variant="subtitle1" className="mb-3 font-semibold"> <PoDetailsPaper dispatch={dispatch} />
Vendor <SitePaper location={dispatch.primaryLocation} />
</Typography> <WorkOrdersPaper
{vendor ? ( workOrders={dispatch.workOrders}
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"> onView={(workOrderId) => navigate(`/workorders/${workOrderId}`)}
<InfoRow label="Company">{vendor.companyName}</InfoRow> />
<InfoRow label="Contact">{vendor.contactName}</InfoRow> <UpliftHistoryPaper
<InfoRow label="Email">{vendor.email}</InfoRow> upliftRequests={dispatch.upliftRequests}
<InfoRow label="Phone">{vendor.phone}</InfoRow> onDownload={(uplift) => void downloadEvidence(uplift)}
<InfoRow label="Address"> onDecide={handleDecide}
{[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>
</Stack> </Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}> <UpliftDecisionDialog
<DialogTitle> action={upliftAction}
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} note={note}
</DialogTitle> noteRequired={noteRequired}
<DialogContent> decisionPending={decisionPending}
<TextField onNoteChange={setNote}
fullWidth onClose={closeUpliftDialog}
multiline onConfirm={handleUpliftConfirm}
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>
</Box> </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

@ -0,0 +1,248 @@
import type { Dispatch, SetStateAction } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material";
import type {
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
import { FilterSection } from "./vendor-filters-drawer-section";
interface VendorFiltersDrawerFieldsProps {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
options: {
companies: VendorFacetCompany[];
trades: string[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
};
}
function CompanyFilterFields({
draft,
setDraft,
companyOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
companyOptions: VendorFacetCompany[];
}) {
return (
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
);
}
function TradeFilterFields({
draft,
setDraft,
tradeOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
tradeOptions: string[];
}) {
return (
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
)}
/>
</FilterSection>
);
}
function LocationFilterFields({
draft,
setDraft,
locationOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
locationOptions: VendorFacetLocation[];
}) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
);
}
function JobBucketFilterFields({
draft,
setDraft,
jobBucketOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
jobBucketOptions: VendorFacetJobBucket[];
}) {
return (
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
);
}
export function VendorFiltersDrawerFields({
draft,
setDraft,
options,
}: VendorFiltersDrawerFieldsProps) {
return (
<>
<CompanyFilterFields draft={draft} setDraft={setDraft} companyOptions={options.companies} />
<TradeFilterFields draft={draft} setDraft={setDraft} tradeOptions={options.trades} />
<LocationFilterFields draft={draft} setDraft={setDraft} locationOptions={options.locations} />
<JobBucketFilterFields
draft={draft}
setDraft={setDraft}
jobBucketOptions={options.jobBuckets}
/>
</>
);
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Stack,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}

View file

@ -1,32 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; import type { VendorFacets } from "@/domain/vendors/types/vendor";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Drawer,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps { interface VendorFiltersDrawerProps {
open: boolean; open: boolean;
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
const DRAWER_WIDTH = 360; const DRAWER_WIDTH = 360;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
export function VendorFiltersDrawer({ export function VendorFiltersDrawer({
open, open,
onClose, onClose,
@ -125,7 +76,7 @@ export function VendorFiltersDrawer({
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2"> <Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
Filters Filters
</Typography> </Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />} {Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
</Stack> </Stack>
<IconButton aria-label="Close filters" onClick={onClose} size="small"> <IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
@ -133,165 +84,16 @@ export function VendorFiltersDrawer({
</Stack> </Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}> <Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}> <VendorFiltersDrawerFields
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}> draft={draft}
<Typography variant="caption" sx={{ color: "text.secondary" }}> setDraft={setDraft}
Select vendor companies options={{
</Typography> companies: companyOptions,
<Button trades: tradeOptions,
size="small" locations: locationOptions,
onClick={() => jobBuckets: jobBucketOptions,
setDraft((current) => ({ }}
...current, />
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.trades.length === 0 ? "All trades" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
</Box> </Box>
<Stack <Stack

View file

@ -67,6 +67,37 @@ function PageShell({
); );
} }
function RosterLoadErrorState({ onBack }: { onBack: () => void }) {
return (
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={onBack}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
}
function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } {
if (isEdit) {
return {
title: "Edit Vendor Company",
subtitle: "Update company details and technicians in one save.",
};
}
return {
title: "Add Vendor Company",
subtitle: "Register a company and add zero, one, or multiple technicians.",
};
}
function submitLabel(isSaving: boolean, isEdit: boolean): string {
if (isSaving) return "Saving...";
return isEdit ? "Save" : "Create";
}
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined; const isEdit = vendorId !== undefined || companyId !== undefined;
@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
} }
if (isEdit && form.isError) { if (isEdit && form.isError) {
return ( return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={() => navigate("/vendors")}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
} }
if (isEdit && !form.roster) { if (isEdit && !form.roster) {
return null; return null;
} }
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; const { title, subtitle } = rosterPageCopy(isEdit);
const subtitle = isEdit
? "Update company details and technicians in one save."
: "Register a company and add zero, one, or multiple technicians.";
const companySelectionProps = isEdit const companySelectionProps = isEdit
? {} ? {}
: { : {
@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSelectCompany: form.selectCompany, onSelectCompany: form.selectCompany,
onClearSelectedCompany: form.clearSelectedCompany, onClearSelectedCompany: form.clearSelectedCompany,
}; };
const showPortalToken = Boolean(isEdit && vendorId);
return ( return (
<PageShell title={title} subtitle={subtitle}> <PageShell title={title} subtitle={subtitle}>
@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
{...companySelectionProps} {...companySelectionProps}
/> />
{isEdit && vendorId !== undefined && vendorId !== "" && ( {showPortalToken && vendorId !== undefined && (
<VendorPortalTokenPanel vendorId={vendorId} /> <VendorPortalTokenPanel vendorId={vendorId} />
)} )}
@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}> <Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"} {submitLabel(form.isSaving, isEdit)}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>

View file

@ -48,7 +48,9 @@ export function VendorsListToolbar({
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} vendor entries · {activeCount} active {globalCount} vendor entries · {activeCount} active
</Typography> </Typography>
{isFetching && !isLoading && <CircularProgress size={16} />} {isFetching && !isLoading && (
<CircularProgress size={16} aria-label="Refreshing vendors" role="status" />
)}
</Stack> </Stack>
<Stack <Stack
@ -62,6 +64,7 @@ export function VendorsListToolbar({
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search name, company, trade..." placeholder="Search name, company, trade..."
className="min-w-[260px]" className="min-w-[260px]"
aria-label="Search vendors"
/> />
<Button <Button
variant="outlined" variant="outlined"

View file

@ -1,127 +1,120 @@
import { useNavigate, useParams } from "react-router"; import { useParams } from "react-router";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton"; import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section"; import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
import { WorkOrderDetailsPanel } from "@/app/(protected)/workorders/_components/view/work-order-details-panel"; import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
import { import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
WorkOrderDescriptionAccordion, import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
WorkOrderInfoAccordions, import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
} from "@/app/(protected)/workorders/_components/view/work-order-info-accordions"; import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_components/view/work-order-dispatches-accordion"; import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
import { import {
detailToClosabilityInput, detailToClosabilityInput,
getClosabilityGaps, getClosabilityGaps,
isCompletionStatusLabel, isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability"; } from "@/domain/work-orders/utils/wo-closability";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
export default function WorkOrderViewPage() { export default function WorkOrderViewPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? ""; const workOrderId = id ?? "";
const controller = useWorkOrderViewController(workOrderId); const view = useWorkOrderViewPage(workOrderId);
const { wo, isLoading, isClosabilityReady, error, refetch, vendorEditor } = controller;
if (isLoading && !wo) { if (view.isLoading && !view.wo) {
return <DetailPageSkeleton variant="view" />; return <DetailPageSkeleton variant="view" />;
} }
if ((error || !wo) && !isLoading) { if (!view.wo) {
return ( return (
<Box className="p-4"> <Box className="p-4">
<QueryErrorPanel <QueryErrorPanel
message={error instanceof Error ? error.message : "Work order not found."} message={view.error instanceof Error ? view.error.message : "Work order not found."}
onRetry={() => void refetch()} onRetry={() => void view.refetch()}
/> />
</Box> </Box>
); );
} }
if (!wo) { const wo = view.wo;
return <DetailPageSkeleton variant="view" />;
}
const handleStatusChange = (status: WorkOrderStatus) => { const handleStatusChange = (status: string) => {
const isCompletionStatus = isCompletionStatusLabel(status); const isCompletionStatus = isCompletionStatusLabel(status);
if ( if (
isCompletionStatus && isCompletionStatus &&
(!isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) (!view.isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0)
) { ) {
return; return;
} }
controller.statusMutation.mutate(status); view.statusMutation.mutate(status as WorkOrderStatus);
}; };
return ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex flex-col gap-4 p-4">
<WorkOrderHeaderSection <WorkOrderViewHeader
wo={wo} wo={wo}
isClosabilityReady={isClosabilityReady} workOrderId={workOrderId}
statusAnchor={controller.statusAnchor} isClosabilityReady={view.isClosabilityReady}
onStatusAnchorChange={controller.setStatusAnchor} statusAnchor={view.statusAnchor}
onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onDispatch={() => controller.setDispatchOpen(true)} onOpenDispatch={() => view.setDispatchOpen(true)}
onEdit={() => navigate(`/workorders/edit/${id}`)}
onBack={() => navigate("/workorders")}
/> />
<WorkOrderDetailsPanel
wo={wo} <WorkOrderViewDetails
users={controller.users} wo={view.wo}
assignAnchor={controller.assignAnchor} users={view.users}
onAssignAnchorChange={controller.setAssignAnchor} assignAnchor={view.assignAnchor}
onAssign={(userId, userName) => controller.assignmentMutation.mutate({ userId, userName })} onAssignAnchorChange={view.setAssignAnchor}
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
/> />
<WorkOrderInfoAccordions wo={wo} />
<WorkOrderViewAccordions wo={view.wo} />
<WorkOrderDispatchesAccordion <WorkOrderDispatchesAccordion
dispatches={wo.dispatches} wo={view.wo}
onSelectDispatch={controller.setDispatchDetailId} onOpenDispatchDetail={view.setDispatchDetailId}
onEditVendor={(event, vendorId, vendorName) => { onOpenVendorEditor={view.openVendorEditor}
event.stopPropagation();
void vendorEditor.openVendorEditor(vendorId, vendorName);
}}
/> />
<WorkOrderDescriptionAccordion description={wo.description} />
<WorkOrderActivityPanel <WorkOrderActivityPanel
activeTab={controller.activeTab} customerComments={view.customerComments}
onTabChange={controller.setActiveTab} internalComments={view.internalComments}
customerComments={controller.customerComments} auditLog={view.auditLog}
internalComments={controller.internalComments} mentionUsers={view.mentionUsers}
auditLog={controller.auditLog} isSubmitting={view.commentMutation.isPending}
commentText={controller.commentText} onSubmitComment={view.submitComment}
onCommentTextChange={controller.setCommentText}
isSubmittingComment={controller.commentMutation.isPending}
onSubmitComment={controller.handleCommentSubmit}
/> />
{controller.dispatchOpen && (
{view.dispatchOpen && (
<DispatchCreateModal <DispatchCreateModal
workOrder={wo} workOrder={view.wo}
onClose={() => controller.setDispatchOpen(false)} onClose={() => view.setDispatchOpen(false)}
onDispatched={() => void refetch()} onDispatched={() => void view.refetch()}
/> />
)} )}
{controller.dispatchDetailId != null && ( {view.dispatchDetailId != null && (
<DispatchDetailModal <DispatchDetailModal
dispatchId={controller.dispatchDetailId} dispatchId={view.dispatchDetailId}
workOrderId={workOrderId} workOrderId={workOrderId}
onClose={() => controller.setDispatchDetailId(null)} onClose={() => view.setDispatchDetailId(null)}
onUpdated={() => void refetch()} onUpdated={() => void view.refetch()}
/> />
)} )}
<WorkOrderVendorEditorDialog <WorkOrderVendorEditorDialog
vendorEditor={vendorEditor.vendorEditor} editor={view.vendorEditor}
isLoading={vendorEditor.vendorEditorLoading} loading={view.vendorEditorLoading}
isSaving={vendorEditor.vendorMutation.isPending} error={view.vendorEditorError}
error={vendorEditor.vendorEditorError} isSaving={view.vendorMutation.isPending}
onClose={vendorEditor.closeVendorEditor} onClose={() => {
onChange={(patch) => if (!view.vendorMutation.isPending) view.setVendorEditor(null);
vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current)) }}
} onSave={view.saveVendorEditor}
onSave={vendorEditor.saveVendorEditor} onEditorChange={view.setVendorEditor}
/> />
</Box> </Box>
); );

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

@ -4,13 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = { type CompDocDialogHeaderProps = {
info: WorkOrderTableRow; row: WorkOrderTableRow;
cfg: CompletionDocStateConfig; cfg: CompletionDocStateConfig;
isUploading: boolean; isUploading: boolean;
onClose: () => void; onClose: () => void;
}; };
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
return ( return (
<div <div
className="flex shrink-0 items-center justify-between" className="flex shrink-0 items-center justify-between"
@ -45,10 +45,10 @@ export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDoc
> >
WO{" "} WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}> <span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{info.woNumber} #{row.woNumber}
</span> </span>
{" · "} {" · "}
{serviceTitle(info)} {serviceTitle(row)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,15 +1,23 @@
import type { MouseEvent } from "react"; import { useRef, useState, type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { toast } from "react-toastify";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import {
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header"; import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
import { CompDocPreview } from "./comp-doc-preview"; import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar"; import { CompDocSidebar } from "./comp-doc-sidebar";
import { useCompDocDialog } from "./use-comp-doc-dialog";
type CompDocDialogProps = { type CompDocDialogProps = {
row: WorkOrderTableRow; row: WorkOrderTableRow;
@ -20,6 +28,59 @@ type CompDocDialogProps = {
onDateCompletedChange?: (date: string) => void | Promise<void>; onDateCompletedChange?: (date: string) => void | Promise<void>;
}; };
type UseCompDocPdfUploadArgs = {
workOrderId: string | number;
rowVersion: string;
onUploaded: () => void | Promise<void>;
};
function rejectCompDocPdf(file: File, rowVersion: string): string | null {
if (isWorkOrderFileTooLarge(file)) {
return `File exceeds the ${formatMediaSizeLimit()} limit.`;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
return "Please upload a PDF file.";
}
if (!rowVersion) {
return "Missing work order version. Refresh and try again.";
}
return null;
}
function useCompDocPdfUpload({ workOrderId, rowVersion, onUploaded }: UseCompDocPdfUploadArgs) {
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId);
const pdfInputRef = useRef<HTMLInputElement>(null);
const [isFinalizing, setIsFinalizing] = useState(false);
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
const rejection = rejectCompDocPdf(file, rowVersion);
if (rejection) {
toast.error(rejection);
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
// Keep close/actions disabled across the gap between upload settle and docStatus persist.
setIsFinalizing(true);
void Promise.resolve(onUploaded()).finally(() => setIsFinalizing(false));
},
},
);
}
return {
pdfInputRef,
handleUploadPdf,
isUploading: uploadCompletionMutation.isPending || isFinalizing,
};
}
export function CompDocDialog({ export function CompDocDialog({
row, row,
open, open,
@ -28,14 +89,33 @@ export function CompDocDialog({
onSave, onSave,
onDateCompletedChange, onDateCompletedChange,
}: CompDocDialogProps) { }: CompDocDialogProps) {
const dialog = useCompDocDialog({ const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
row, const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
open,
data, const templateUrl = boardDetail?.completion.template?.templateUrl;
onSave, const signOffAttachment = boardDetail?.completion.signOffAttachment || null;
onDateCompletedChange, const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// Uploading a signed PDF finalizes through the same Generate flow, so the
// completedDate/address prerequisites still persist before docStatus Yes.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,
onUploaded: dialog.handleGenerate,
}); });
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
// A signed PDF on file means the document exists even if docState has not caught up.
const isGenerated = dialog.isGenerated || Boolean(signOffAttachment);
const handleClose = upload.isUploading ? undefined : () => onOpenChange(false);
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
dialog.handleDownloadPdf();
}
return ( return (
<Dialog <Dialog
@ -66,45 +146,45 @@ export function CompDocDialog({
overflow: "hidden", overflow: "hidden",
}} }}
> >
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
Completion Document — WO #{dialog.info.woNumber}
</DialogTitle>
<Text className="sr-only" variant="description"> <Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order. Generate, save or mark not-required the completion document for this work order.
</Text> </Text>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<CompDocDialogHeader <CompDocDialogHeader
info={dialog.info} row={row}
cfg={dialog.cfg} cfg={dialog.cfg}
isUploading={dialog.isUploading} isUploading={upload.isUploading}
onClose={() => handleClose?.()} onClose={() => handleClose?.()}
/> />
<CompDocDialogResourceLinks <CompDocDialogResourceLinks
templateUrl={dialog.templateUrl} templateUrl={templateUrl}
signOffAttachment={dialog.signOffAttachment} signOffAttachment={signOffAttachment}
/> />
<div className="flex min-h-0 flex-1 flex-row"> <div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview <CompDocPreview
row={dialog.info} row={row}
site={dialog.site} site={dialog.site}
notes={dialog.notes} notes={dialog.notes}
generatedAt={dialog.generatedAt} generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated} isGenerated={isGenerated}
isNotRequired={dialog.isNotRequired} isNotRequired={dialog.isNotRequired}
/> />
<CompDocSidebar <CompDocSidebar
row={dialog.info} row={row}
site={dialog.site} site={dialog.site}
docState={dialog.docState} docState={dialog.docState}
generatedAt={dialog.generatedAt} generatedAt={dialog.generatedAt}
notes={dialog.notes} notes={dialog.notes}
editAddress={dialog.editAddress}
onAddressChange={dialog.setEditAddress}
editDateCompleted={dialog.editDateCompleted} editDateCompleted={dialog.editDateCompleted}
amazonRep={dialog.amazonRep} amazonRep={dialog.amazonRep}
seaRep={dialog.seaRep} seaRep={dialog.seaRep}
isGenerated={dialog.isGenerated} isGenerated={isGenerated}
onNotesChange={dialog.setNotes} onNotesChange={dialog.setNotes}
onDateCompletedChange={dialog.setEditDateCompleted} onDateCompletedChange={dialog.setEditDateCompleted}
/> />
@ -112,18 +192,18 @@ export function CompDocDialog({
<CompDocDialogFooter <CompDocDialogFooter
docState={dialog.docState} docState={dialog.docState}
isDocumentsReadOnly={dialog.isDocumentsReadOnly} isDocumentsReadOnly={isDocumentsReadOnly}
rowVersion={dialog.rowVersion} rowVersion={rowVersion}
isUploading={dialog.isUploading} isUploading={upload.isUploading}
signOffAttachment={dialog.signOffAttachment} signOffAttachment={signOffAttachment}
generatedAt={dialog.generatedAt} generatedAt={dialog.generatedAt}
pdfInputRef={dialog.pdfInputRef} pdfInputRef={upload.pdfInputRef}
onMarkNotRequired={dialog.handleMarkNotRequired} onMarkNotRequired={dialog.handleMarkNotRequired}
onSaveDraft={dialog.handleSaveDraft} onSaveDraft={dialog.handleSaveDraft}
onGenerate={dialog.handleGenerate} onGenerate={dialog.handleGenerate}
onUndo={dialog.handleUndo} onUndo={dialog.handleUndo}
onDownloadPdf={dialog.handleDownloadPdf} onDownloadPdf={handleDownloadPdf}
onUploadPdf={dialog.handleUploadPdf} onUploadPdf={upload.handleUploadPdf}
/> />
</div> </div>
</DialogContent> </DialogContent>

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

@ -1,8 +1,10 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocPreviewDocument } from "./comp-doc-preview-document"; import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp"; import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge"; import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
type CompDocPreviewProps = { type CompDocPreviewProps = {
row: WorkOrderTableRow; row: WorkOrderTableRow;
@ -30,7 +32,7 @@ export function CompDocPreview({
background: "var(--color-bg-muted)", background: "var(--color-bg-muted)",
}} }}
> >
<CompDocPreviewStatusBadge isGenerated={isGenerated} /> <CompDocPreviewBadge isGenerated={isGenerated} />
<div <div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto" className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
@ -48,16 +50,38 @@ export function CompDocPreview({
wordBreak: "break-word", wordBreak: "break-word",
}} }}
> >
<CompDocPreviewDocument <div
row={row} style={{
site={site} opacity: isNotRequired ? 0.5 : 1,
notes={notes} filter: isNotRequired ? "grayscale(0.4)" : "none",
generatedAt={generatedAt} }}
isGenerated={isGenerated} >
isNotRequired={isNotRequired} <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 && <CompDocPreviewNotRequiredStamp />} {isNotRequired && <CompDocNotRequiredStamp />}
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,26 +7,29 @@ type CompDocProcedureListProps = {
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) { export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return ( return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}> <ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => ( {proc.steps.map((st, i) => {
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}> const subSteps = st.sub ?? [];
{st.text} return (
{st.sub != null && ( <li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
<ol {st.text}
style={{ {subSteps.length > 0 && (
listStyle: "lower-alpha", <ol
paddingLeft: 22, style={{
margin: "4px 0 0", listStyle: "lower-alpha",
}} paddingLeft: 22,
> margin: "4px 0 0",
{st.sub.map((x, j) => ( }}
<li key={j} style={{ margin: "2px 0" }}> >
{x} {subSteps.map((x, j) => (
</li> <li key={j} style={{ margin: "2px 0" }}>
))} {x}
</ol> </li>
)} ))}
</li> </ol>
))} )}
</li>
);
})}
</ol> </ol>
); );
} }

View file

@ -1,12 +1,8 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc"; import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { import { CompDocDetailsSection } from "./comp-doc-details-section";
DOC_STATE_CFG, import { CompDocNotesField } from "./comp-doc-notes-field";
docStatusCardDescription, import { CompDocStatusCard } from "./comp-doc-status-card";
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditRow } from "./comp-doc-edit-row";
type CompDocSidebarProps = { type CompDocSidebarProps = {
row: WorkOrderTableRow; row: WorkOrderTableRow;
@ -14,6 +10,8 @@ type CompDocSidebarProps = {
docState: DocState; docState: DocState;
generatedAt: string; generatedAt: string;
notes: string; notes: string;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string; editDateCompleted: string;
amazonRep: string; amazonRep: string;
seaRep: string; seaRep: string;
@ -28,6 +26,8 @@ export function CompDocSidebar({
docState, docState,
generatedAt, generatedAt,
notes, notes,
editAddress,
onAddressChange,
editDateCompleted, editDateCompleted,
amazonRep, amazonRep,
seaRep, seaRep,
@ -35,139 +35,25 @@ export function CompDocSidebar({
onNotesChange, onNotesChange,
onDateCompletedChange, onDateCompletedChange,
}: CompDocSidebarProps) { }: CompDocSidebarProps) {
const displayState: DocState =
isGenerated && docState !== "not-required" ? "generated" : docState;
const cfg = DOC_STATE_CFG[displayState];
const StateIcon = cfg.icon;
return ( return (
<div <div
className="flex min-h-0 flex-col" className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }} style={{ width: "42%", background: "var(--color-bg-surface)" }}
> >
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}> <div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
<div <CompDocStatusCard docState={docState} generatedAt={generatedAt} />
className="flex items-start gap-3" <CompDocDetailsSection
style={{ row={row}
padding: 14, site={site}
borderRadius: 10, editAddress={editAddress}
background: cfg.cardBg, onAddressChange={onAddressChange}
border: `1px solid ${cfg.cardBorder}`, editDateCompleted={editDateCompleted}
}} onDateCompletedChange={onDateCompletedChange}
> amazonRep={amazonRep}
<div seaRep={seaRep}
className="flex shrink-0 items-center justify-center" isGenerated={isGenerated}
style={{ />
width: 34, <CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
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(displayState, generatedAt)}
</div>
</div>
</div>
<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`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
<CompDocEditRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
readOnly={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
<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>
</div> </div>
</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,4 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "react-toastify";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
@ -72,7 +73,12 @@ export function useSlideOverMediaState({
}; };
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => { const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
categorizeMediaMutation.mutate({ mediaId, category }); const workOrderVersion = detailForRow?.info.rowVersion;
if (!workOrderVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
}; };
const removeMedia = (id: string) => { const removeMedia = (id: string) => {
@ -80,7 +86,12 @@ export function useSlideOverMediaState({
removeLocal(id); removeLocal(id);
return; return;
} }
deleteMediaMutation.mutate(id); const workOrderVersion = detailForRow?.info.rowVersion;
if (!workOrderVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
}; };
return { return {

View file

@ -147,7 +147,7 @@ export function WorkOrderSlideOverTabs({
onUpload={(files) => { onUpload={(files) => {
void state.uploadExtraFiles(files); void state.uploadExtraFiles(files);
}} }}
onRemove={(id) => state.deleteMediaMutation.mutate(id)} onRemove={state.removeMedia}
/> />
</WorkOrderSlideOverTabPanel> </WorkOrderSlideOverTabPanel>
)} )}

View file

@ -72,7 +72,10 @@ export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyP
uplifts={controller.uplifts} uplifts={controller.uplifts}
isLocked={controller.isLocked} isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })} 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 })
}
/> />
)} )}

View file

@ -1,82 +1,178 @@
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material"; import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { formatDateTime } from "@/lib/time-utils"; import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
type UpliftAction = "approve" | "reject" | "request-changes";
type DispatchUpliftsSectionProps = { type DispatchUpliftsSectionProps = {
uplifts: UpliftRequest[]; uplifts: UpliftRequest[];
isLocked: boolean; isLocked: boolean;
onApprove: (uplift: UpliftRequest) => void; 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"> & { type UpliftCardProps = Omit<DispatchUpliftsSectionProps, "uplifts"> & {
uplift: UpliftRequest; uplift: UpliftRequest;
}; };
function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) { type UpliftCardActionsProps = Pick<
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; 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 ( return (
<Box className="rounded border border-border p-3"> <>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}> <Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Chip label={uplift.status} size="small" /> <Chip label={uplift.status} size="small" />
{uplift.requiredTier === 2 && ( {uplift.requiredTier === 2 && (
<Typography variant="caption">Tier 2 · Manager approval</Typography> <Typography variant="caption">Tier 2 · Manager approval</Typography>
)} )}
<Typography variant="body2"> <Typography variant="body2">
${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "} ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}
(+${Number(uplift.delta).toFixed(2)})
</Typography> </Typography>
</Stack> </Stack>
{Boolean(uplift.expiresAt) && (
<Typography variant="caption" color="text.secondary" className="mt-1 block">
Expires {formatDateTime(uplift.expiresAt)}
</Typography>
)}
{Boolean(uplift.vendorReason) && ( {Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1"> <Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason} <strong>Reason:</strong> {uplift.vendorReason}
</Typography> </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) && ( {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography variant="body2" color="text.secondary" className="mt-1"> <Typography variant="body2" color="text.secondary" className="mt-1">
Requested by {uplift.requestedByVendorName || "vendor"} Requested by {uplift.requestedByVendorName || "vendor"}
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
</Typography> </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"> function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) {
{uplift.status} by {uplift.decidedByName || "—"} if (uplift.status === "Pending") return null;
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null;
</Typography> return (
)} <Box className="mt-1">
{Boolean(uplift.decisionNote) && ( {Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" className="mt-0.5"> <Typography variant="body2" color="text.secondary">
<strong>Note:</strong> {uplift.decisionNote} {uplift.status} by {uplift.decidedByName || "—"}
</Typography> {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
)} </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>
)} )}
{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> </Box>
); );
} }
@ -85,7 +181,8 @@ export function DispatchUpliftsSection({
uplifts, uplifts,
isLocked, isLocked,
onApprove, onApprove,
onDeny, onReject,
onRequestChanges,
}: DispatchUpliftsSectionProps) { }: DispatchUpliftsSectionProps) {
return ( return (
<Box> <Box>
@ -102,7 +199,8 @@ export function DispatchUpliftsSection({
uplift={uplift} uplift={uplift}
isLocked={isLocked} isLocked={isLocked}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onReject={onReject}
onRequestChanges={onRequestChanges}
/> />
))} ))}
</Stack> </Stack>

View file

@ -21,6 +21,10 @@ export function DispatchDetailModal({
}: DispatchDetailModalProps) { }: DispatchDetailModalProps) {
const controller = useDispatchDetailController(dispatchId, workOrderId); const controller = useDispatchDetailController(dispatchId, workOrderId);
const handleClose = guardDialogClose(controller.isMutating, onClose); const handleClose = guardDialogClose(controller.isMutating, onClose);
const isDecidingUplift =
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending;
if (controller.isLoading && !controller.dispatch) { if (controller.isLoading && !controller.dispatch) {
return ( return (
@ -77,17 +81,12 @@ export function DispatchDetailModal({
requestedNTE={controller.upliftAction.uplift.requestedNTE} requestedNTE={controller.upliftAction.uplift.requestedNTE}
currentNTE={controller.upliftAction.uplift.currentNTE} currentNTE={controller.upliftAction.uplift.currentNTE}
onClose={() => { onClose={() => {
if ( if (!isDecidingUplift) {
!controller.approveUpliftMutation.isPending &&
!controller.denyUpliftMutation.isPending
) {
controller.setUpliftAction(null); controller.setUpliftAction(null);
} }
}} }}
onConfirm={controller.handleUpliftConfirm} onConfirm={controller.handleUpliftConfirm}
isPending={ isPending={isDecidingUplift}
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
}
/> />
)} )}
</> </>

View file

@ -12,7 +12,7 @@ import { guardDialogClose } from "@/lib/dialog-guard";
type UpliftDecisionDialogProps = { type UpliftDecisionDialogProps = {
open: boolean; open: boolean;
type: "approve" | "deny"; type: "approve" | "reject" | "request-changes";
requestedNTE: number; requestedNTE: number;
currentNTE: number | null; currentNTE: number | null;
onClose: () => void; onClose: () => void;
@ -20,6 +20,13 @@ type UpliftDecisionDialogProps = {
isPending?: boolean; 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({ export function UpliftDecisionDialog({
open, open,
type, type,
@ -30,6 +37,7 @@ export function UpliftDecisionDialog({
isPending = false, isPending = false,
}: UpliftDecisionDialogProps) { }: UpliftDecisionDialogProps) {
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const noteRequired = type === "reject" || type === "request-changes";
const handleClose = () => { const handleClose = () => {
if (isPending) return; if (isPending) return;
@ -38,14 +46,13 @@ export function UpliftDecisionDialog({
}; };
const handleConfirm = () => { const handleConfirm = () => {
if (noteRequired && !note.trim()) return;
onConfirm(note.trim()); onConfirm(note.trim());
}; };
return ( return (
<Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}> <Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
<DialogTitle> <DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle>
<DialogContent> <DialogContent>
{type === "approve" && ( {type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3"> <Typography variant="body2" className="mt-1 mb-3">
@ -58,7 +65,8 @@ export function UpliftDecisionDialog({
multiline multiline
minRows={2} minRows={2}
className="mt-2" className="mt-2"
label={type === "deny" ? "Denial reason (required)" : "Optional note"} label={noteRequired ? "Note (required)" : "Optional note"}
required={noteRequired}
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
disabled={isPending} disabled={isPending}
@ -69,8 +77,8 @@ export function UpliftDecisionDialog({
Cancel Cancel
</Button> </Button>
<Button <Button
variant={type === "approve" ? "default" : "destructive"} variant={type === "reject" ? "destructive" : "default"}
disabled={type === "deny" ? !note.trim() : false} disabled={noteRequired && !note.trim()}
loading={isPending} loading={isPending}
loadingText="Confirming…" loadingText="Confirming…"
onClick={handleConfirm} onClick={handleConfirm}

View file

@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react"; import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; 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 { function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive"; return type === "Emergency" || type === "Reactive";
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = { type CompDocCellProps = {
status: CompletionDocStatus; status: CompletionDocStatus;
woType: WOType | ""; woType: WOType | "";
/** Primary service (`pm`). Required before CompDoc can open. */
pm?: string;
mediaCount?: number; mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */ /** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void; onOpen?: () => void;
@ -18,6 +21,7 @@ type CompDocCellProps = {
export function CompDocCell({ export function CompDocCell({
status, status,
woType, woType,
pm = "",
mediaCount = 0, mediaCount = 0,
onOpen, onOpen,
onOpenMedia, onOpenMedia,
@ -43,6 +47,20 @@ export function CompDocCell({
return null; 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 generated = status === "Yes";
const notRequired = status === "NN"; const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)"; 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

@ -54,7 +54,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget); 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" 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)" }} style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
> >
@ -88,8 +88,7 @@ export function StatusCell({
color: "var(--status-pending-text)", color: "var(--status-pending-text)",
}} }}
> >
This work order is past due. A new date must be scheduled before the status can be This work order is past due. Update the Due Date before the status can be updated.
updated.
</Text> </Text>
<button <button
@ -103,7 +102,7 @@ export function StatusCell({
}} }}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]" className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
> >
Reschedule → Update Due Date →
</button> </button>
</div> </div>
</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"; import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = { type VendorCellProps = {
vendorId?: string | number;
company: string; company: string;
tech: string; tech: string;
techPhone?: string; techPhone?: string;
@ -13,7 +14,15 @@ type VendorCellProps = {
onSave: (patch: VendorPatch) => void; 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 [open, setOpen] = useState(false);
const hasAny = !!(tech || company); const hasAny = !!(tech || company);
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<VendorDialog <VendorDialog
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
vendorId={vendorId}
company={company} company={company}
tech={tech} tech={tech}
techPhone={techPhone} 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 { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; 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 { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; 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 { import {
buildTechnicianOptions, buildTechnicianOptions,
phoneForOption, phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; } 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 = { export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
type VendorDialogProps = { type VendorDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
vendorId?: string | number;
company: string; company: string;
tech: string; tech: string;
techPhone?: string; techPhone?: string;
@ -37,43 +35,94 @@ type VendorDialogProps = {
export function VendorDialog({ export function VendorDialog({
open, open,
onOpenChange, onOpenChange,
vendorId = "",
company, company,
tech, tech,
techPhone = "", techPhone = "",
vendors, vendors,
onSave, onSave,
}: VendorDialogProps) { }: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech); const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone); const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState(""); 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( const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company), () => buildTechnicianOptions(allVendors, tech, company),
[vendors, tech, company], [allVendors, tech, company],
); );
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech); setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions)); setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes(""); 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 = const selected = technicianOptions.find((option) => option.key === selectedKey);
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ?? const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
technicianOptions.find((o) => o.name === t || o.key === t); const selectedVendor =
const selectedKey = selected?.key ?? ""; selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany); const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => { const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key); const option = technicianOptions.find((o) => o.key === key);
const name = option?.name ?? key; setSelectedKey(key);
setT(name); setT(option?.name ?? key);
setPhone(phoneForOption(name, technicianOptions)); 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 ( return (
<Dialog <Dialog
open={open} open={open}
@ -96,84 +145,28 @@ export function VendorDialog({
Search and select the technician Search and select the technician
</Text> </Text>
<div className="mt-3 space-y-3"> <VendorDialogBody
<div> selectedKey={selectedKey}
<FieldLabel>Technician</FieldLabel> technicianOptions={technicianOptions}
<WizardFieldSelect anchorCompanyName={anchorCompanyName}
value={selectedKey} companyVendorForAdd={companyVendor ?? undefined}
placeholder="Search technician …" tech={selected?.name ?? t}
icon={ phone={phone}
<Wrench notes={notes}
size={14} resolvedCompany={resolvedCompany}
className="shrink-0" selectedVendor={selectedVendor}
style={{ color: "var(--color-text-muted)" }} saveError={saveError}
/> onPick={handlePick}
} onPhoneChange={setPhone}
searchPlaceholder="Search technician…" onNotesChange={setNotes}
options={technicianOptions.map((option) => ({ onTechnicianCreated={handleTechnicianCreated}
key: option.key, />
label: option.label,
}))}
onPick={handlePick}
/>
</div>
{Boolean(t) && ( <VendorDialogFooter
<div> isPending={createVendor.isPending}
<FieldLabel>Technician Phone</FieldLabel> onCancel={() => onOpenChange(false)}
<input onSave={handleSave}
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>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
import { AssignedToCell } from "./cells/assigned-to-cell"; import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell"; import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell"; import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell"; import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell"; import { SiteCell } from "./cells/site-cell";
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <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>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell <VendorCell
vendorId={draft.vendorId}
company={draft.company} company={draft.company}
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} 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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell"; import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell"; import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell"; import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string }; sitePlace?: { city: string; state: string };
search?: string; search?: string;
forceScheduleHighlight?: boolean; forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties; mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers; handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void; onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace, sitePlace,
search, search,
forceScheduleHighlight, forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle, mutationCellStyle,
handlers, handlers,
onPatchRow, onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell <DateCell
value={row.scheduledOn} value={row.scheduledOn}
q={search} q={search}
highlight={forceScheduleHighlight || row.isPastDue} highlight={forceScheduleHighlight}
allowWeekMode allowWeekMode
targetWeek={row.targetWeek} targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)} onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()} 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> </td>
</> </>
); );

View file

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

View file

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

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters, onOpenAdvancedFilters,
onPatch, onPatch,
}: WoTableProps) { }: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null); const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites(); const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => { const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc, onOpenCompDoc,
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight: setScheduleHighlightId, onScheduleHighlight: setDueDateHighlightId,
}); });
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
search={tableData.search} search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id} forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id} isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id} isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz} 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,6 +1,7 @@
import { type MouseEvent } from "react"; import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react"; import { Image as ImageIcon } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { LoadingRegion } from "@/components/ui/loading-region"; import { LoadingRegion } from "@/components/ui/loading-region";
@ -23,6 +24,14 @@ type MediaUploadModalProps = {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}; };
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) { export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id); const uploadMutation = useUploadWorkOrderMedia(row.id);
@ -36,6 +45,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending; uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false)); 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 ( return (
<Dialog <Dialog
open={open} open={open}
@ -79,9 +97,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
removeLocal(id); removeLocal(id);
return; return;
} }
deleteMutation.mutate(id); 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 });
}} }}
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
onRetry={retryUpload} onRetry={retryUpload}
onCancel={cancelUpload} onCancel={cancelUpload}
readOnly={isReadOnly} readOnly={isReadOnly}

View file

@ -1,42 +1,56 @@
import { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; import { useMemo, useState } from "react";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order"; import { Box, Button, Paper, Tab, Tabs } from "@mui/material";
import { import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
AuditTimeline, import { CommentTimeline } from "@/components/common/comment-timeline";
CommentTimeline, import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
} from "@/app/(protected)/workorders/_components/view/work-order-timelines"; import type {
import type { WorkOrderActivityTab } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller"; 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";
interface WorkOrderActivityPanelProps { export type WorkOrderActivityTab = "customer" | "internal" | "audit";
activeTab: WorkOrderActivityTab;
onTabChange: (tab: WorkOrderActivityTab) => void; type MentionUser = { id: string | number; name: string };
type WorkOrderActivityPanelProps = {
customerComments: WorkOrderComment[]; customerComments: WorkOrderComment[];
internalComments: WorkOrderComment[]; internalComments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[]; auditLog: WorkOrderAuditEntry[];
commentText: string; mentionUsers: MentionUser[];
onCommentTextChange: (text: string) => void; isSubmitting: boolean;
isSubmittingComment: boolean; onSubmitComment: (text: string) => void | Promise<void>;
onSubmitComment: (event: React.FormEvent) => void; };
}
export function WorkOrderActivityPanel({ export function WorkOrderActivityPanel({
activeTab,
onTabChange,
customerComments, customerComments,
internalComments, internalComments,
auditLog, auditLog,
commentText, mentionUsers,
onCommentTextChange, isSubmitting,
isSubmittingComment,
onSubmitComment, onSubmitComment,
}: WorkOrderActivityPanelProps) { }: WorkOrderActivityPanelProps) {
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const customerTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(customerComments),
[customerComments],
);
const internalTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(internalComments),
[internalComments],
);
return ( return (
<Paper variant="outlined" className="p-4"> <Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold"> <Text as="p" variant="heading" className="mb-3 font-semibold">
Activity Activity
</Typography> </Text>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(_event, value: WorkOrderActivityTab) => onTabChange(value)} onChange={(_event, value: WorkOrderActivityTab) => setActiveTab(value)}
className="mb-3" className="mb-3"
> >
<Tab value="customer" label={`Customer (${customerComments.length})`} /> <Tab value="customer" label={`Customer (${customerComments.length})`} />
@ -44,27 +58,47 @@ export function WorkOrderActivityPanel({
<Tab value="audit" label={`Audit Log (${auditLog.length})`} /> <Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs> </Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />} {activeTab === "customer" && (
{activeTab === "internal" && <CommentTimeline comments={internalComments} />} <CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
{activeTab === "audit" && <AuditTimeline entries={auditLog} />} )}
{activeTab === "internal" && (
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
)}
{activeTab === "audit" && <WorkOrderAuditTimeline entries={auditLog} />}
{activeTab === "internal" && ( {activeTab === "internal" && (
<Box component="form" onSubmit={onSubmitComment} className="mt-4 flex flex-col gap-2"> <Box
<TextField component="form"
multiline onSubmit={(event) => {
rows={2} 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} value={commentText}
onChange={(event) => onCommentTextChange(event.target.value)} onChange={setCommentText}
placeholder="Type a comment..." candidates={mentionUsers}
disabled={isSubmittingComment} disabled={isSubmitting}
/> />
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
className="self-end" className="self-end"
disabled={isSubmittingComment || !commentText.trim()} disabled={isSubmitting || !commentText.trim()}
> >
{isSubmittingComment ? "Sending..." : "Send"} {isSubmitting ? "Sending..." : "Send"}
</Button> </Button>
</Box> </Box>
)} )}

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

@ -12,47 +12,31 @@ import {
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/work-order"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
interface WorkOrderDispatchesAccordionProps { type WorkOrderDispatchesAccordionProps = {
dispatches: WorkOrderDispatchSummary[]; wo: WorkOrderDetail;
onSelectDispatch: (dispatchId: string | number) => void; onOpenDispatchDetail: (dispatchId: string | number) => void;
onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void; onOpenVendorEditor: (
} event: React.MouseEvent,
vendorId: string | number,
function DispatchWaitCell({ dispatch }: { dispatch: WorkOrderDispatchSummary }) { vendorName: string,
if (dispatch.status !== "Sent") { ) => void;
return <>—</>; };
}
const waitColor = waitTimeColor(dispatch.dispatchedAt);
const colorClass =
waitColor === "green"
? "text-green-600"
: waitColor === "yellow"
? "text-yellow-600"
: "text-red-600";
return (
<Typography variant="caption" className={colorClass}>
{timeSince(dispatch.dispatchedAt)}
</Typography>
);
}
export function WorkOrderDispatchesAccordion({ export function WorkOrderDispatchesAccordion({
dispatches, wo,
onSelectDispatch, onOpenDispatchDetail,
onEditVendor, onOpenVendorEditor,
}: WorkOrderDispatchesAccordionProps) { }: WorkOrderDispatchesAccordionProps) {
return ( return (
<Accordion defaultExpanded={Boolean(dispatches.length)}> <Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Dispatches ({dispatches.length})</Typography> <Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
{dispatches.length ? ( {wo.dispatches.length ? (
<TableContainer> <TableContainer>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@ -67,49 +51,61 @@ export function WorkOrderDispatchesAccordion({
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{dispatches.map((dispatch) => ( {wo.dispatches.map((dispatch) => {
<TableRow const waitColor = waitTimeColor(dispatch.dispatchedAt);
key={String(dispatch.id)} return (
hover <TableRow
className="cursor-pointer" key={String(dispatch.id)}
onClick={() => onSelectDispatch(dispatch.id)} hover
> className="cursor-pointer"
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell> onClick={() => onOpenDispatchDetail(dispatch.id)}
<TableCell>{dispatch.vendorName}</TableCell> >
<TableCell> <TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} <TableCell>{dispatch.vendorName}</TableCell>
</TableCell> <TableCell>
<TableCell>{dispatch.status}</TableCell> {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
<TableCell> </TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} <TableCell>{dispatch.status}</TableCell>
</TableCell> <TableCell>
<TableCell> {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
<DispatchWaitCell dispatch={dispatch} /> </TableCell>
</TableCell> <TableCell>
<TableCell align="right"> {dispatch.status === "Sent" ? (
<Button <Typography
size="small" variant="caption"
onClick={(event) => className={
onEditVendor(event, dispatch.vendorId, dispatch.vendorName) waitColor === "green"
} ? "text-green-600"
disabled={dispatch.vendorId === ""} : waitColor === "yellow"
> ? "text-yellow-600"
Edit vendor : "text-red-600"
</Button> }
</TableCell> >
</TableRow> {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> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
) : ( ) : (
<Typography <Typography sx={{ color: "text.secondary" }}>No dispatches yet.</Typography>
sx={{
color: "text.secondary",
}}
>
No dispatches yet.
</Typography>
)} )}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>

View file

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

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

@ -1,10 +1,7 @@
import { Link as RouterLink } from "react-router"; import { Link as RouterLink, useNavigate } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material"; 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"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import {
WORK_ORDER_STATUS_OPTIONS,
type WorkOrderStatus,
} from "@/domain/work-orders/types/work-order";
import { import {
detailToClosabilityInput, detailToClosabilityInput,
formatClosabilityMissingTitle, formatClosabilityMissingTitle,
@ -25,28 +22,27 @@ const STATUS_COLORS: Record<
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements"; const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
interface WorkOrderHeaderSectionProps { type WorkOrderViewHeaderProps = {
wo: WorkOrderDetail; wo: WorkOrderDetail;
workOrderId: string;
/** False while board detail is pending/errored — fail closed for Done. */ /** False while board detail is pending/errored — fail closed for Done. */
isClosabilityReady: boolean; isClosabilityReady: boolean;
statusAnchor: HTMLElement | null; statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void; onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: WorkOrderStatus) => void; onStatusChange: (status: string) => void;
onDispatch: () => void; onOpenDispatch: () => void;
onEdit: () => void; };
onBack: () => void;
}
export function WorkOrderHeaderSection({ export function WorkOrderViewHeader({
wo, wo,
workOrderId,
isClosabilityReady, isClosabilityReady,
statusAnchor, statusAnchor,
onStatusAnchorChange, onStatusAnchorChange,
onStatusChange, onStatusChange,
onDispatch, onOpenDispatch,
onEdit, }: WorkOrderViewHeaderProps) {
onBack, const navigate = useNavigate();
}: WorkOrderHeaderSectionProps) {
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo)); const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
return ( return (
@ -55,21 +51,12 @@ export function WorkOrderHeaderSection({
<Link component={RouterLink} to="/workorders" underline="hover"> <Link component={RouterLink} to="/workorders" underline="hover">
Work Orders Work Orders
</Link> </Link>
<Typography <Typography sx={{ color: "text.primary" }}>{wo.internalWONumber}</Typography>
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
</Breadcrumbs> </Breadcrumbs>
<Stack <Stack
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
spacing={2} spacing={2}
sx={{ sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", md: "flex-start" } }}
justifyContent: "space-between",
alignItems: { xs: "stretch", md: "flex-start" },
}}
> >
<Stack spacing={1}> <Stack spacing={1}>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
@ -116,20 +103,14 @@ export function WorkOrderHeaderSection({
</Menu> </Menu>
</Stack> </Stack>
<Stack <Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
direction="row" <Button variant="contained" color="success" onClick={onOpenDispatch}>
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Button variant="contained" color="success" onClick={onDispatch}>
Dispatch Dispatch
</Button> </Button>
<Button variant="contained" onClick={onEdit}> <Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
Edit Edit
</Button> </Button>
<Button variant="outlined" onClick={onBack}> <Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List Back to List
</Button> </Button>
</Stack> </Stack>

View file

@ -52,13 +52,21 @@ export function useWizardDateFieldState({
const [rangeStart, setRangeStart] = useState(value || ""); const [rangeStart, setRangeStart] = useState(value || "");
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || ""); const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
useEffect(() => {
if (!open) {
return;
}
// Keep draft props as source of truth while open so parent clears
// (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
}, [open, value, scheduleEnd]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
setCellMode(initialMode); setCellMode(initialMode);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
}, [open]); }, [open]);
function handleRangeClick(iso: string) { function handleRangeClick(iso: string) {

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

@ -28,18 +28,14 @@ export function WizardStepLocationServiceSelect({
onUpdate, onUpdate,
onPatch, onPatch,
}: WizardStepLocationServiceSelectProps) { }: WizardStepLocationServiceSelectProps) {
const svcSel = [draft.pm, ...draft.extraServices].filter((p, i, a) => !!p && a.indexOf(p) === i);
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> => const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
const toggleSvc = (p: string) => { const selectSvc = (p: string) => {
const next = svcSel.includes(p) ? svcSel.filter((x) => x !== p) : [...svcSel, p];
const nextPm = next[0] ?? "";
onPatch({ onPatch({
pm: nextPm, pm: p,
extraServices: next.slice(1), extraServices: [],
...clearVendorIfPrimaryChanged(nextPm), ...clearVendorIfPrimaryChanged(p),
}); });
}; };
@ -63,13 +59,7 @@ export function WizardStepLocationServiceSelect({
), ),
}; };
})} })}
onPick={(p) => onPick={selectSvc}
onPatch({
pm: p,
extraServices: draft.extraServices.filter((x) => x !== p),
...clearVendorIfPrimaryChanged(p),
})
}
/> />
{!svcOpen ? ( {!svcOpen ? (
<button <button
@ -95,8 +85,8 @@ export function WizardStepLocationServiceSelect({
> >
<WizardServiceNotesFields <WizardServiceNotesFields
services={services} services={services}
sel={svcSel} sel={draft.pm ? [draft.pm] : []}
onToggle={toggleSvc} onToggle={selectSvc}
note={draft.pmNote} note={draft.pmNote}
setNote={(v) => onUpdate("pmNote", v)} setNote={(v) => onUpdate("pmNote", v)}
search={svcSearch} search={svcSearch}

View file

@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import { WO_TYPE_META, WO_TYPES, type WOType } 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 { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; 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 { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
@ -239,7 +240,8 @@ export function WizardStepTypeSchedule({
> >
Due Date Due Date
</WizLabelRow> </WizLabelRow>
<WizardDateField <WizardDueDateField
type={draft.type}
value={draft.dueDate} value={draft.dueDate}
placeholder="No due date" placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)} onChange={(iso) => onUpdate("dueDate", iso)}

View file

@ -1,4 +1,4 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { import {
@ -74,7 +74,7 @@ function WizardVendorDetail({
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}> <div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "—"} {vendor.companyName || "ÔÇö"}
</div> </div>
{Boolean(distance) && ( {Boolean(distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}> <span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
@ -155,7 +155,7 @@ export function WizardStepVendorTime({
</WizLabelRow> </WizLabelRow>
<WizardFieldSelect <WizardFieldSelect
value={draft.vendorId} value={draft.vendorId}
placeholder="Select technician…" placeholder="Select technicianÔǪ"
icon={ icon={
selectedVendor ? ( selectedVendor ? (
<span <span
@ -178,7 +178,7 @@ export function WizardStepVendorTime({
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} /> <Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
) )
} }
searchPlaceholder="Search technician…" searchPlaceholder="Search technicianÔǪ"
options={vendors.map((v) => ({ options={vendors.map((v) => ({
key: String(v.id), key: String(v.id),
label: formatVendorTechnicianLabel(v), label: formatVendorTechnicianLabel(v),
@ -258,7 +258,7 @@ export function WizardStepVendorTime({
<WizLabel>Status</WizLabel> <WizLabel>Status</WizLabel>
<WizardFieldSelect <WizardFieldSelect
value={statusValue} value={statusValue}
placeholder="Select status…" placeholder="Select statusÔǪ"
search={false} search={false}
icon={<ColorDot color={statusDotColor} />} icon={<ColorDot color={statusDotColor} />}
options={buildWizardStatusOptions(closabilityGaps)} options={buildWizardStatusOptions(closabilityGaps)}

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

@ -172,7 +172,8 @@ export function useDispatchDetailController(
addSignoffMutation, addSignoffMutation,
verifyMutation, verifyMutation,
approveUpliftMutation: uplift.approveUpliftMutation, approveUpliftMutation: uplift.approveUpliftMutation,
denyUpliftMutation: uplift.denyUpliftMutation, rejectUpliftMutation: uplift.rejectUpliftMutation,
requestChangesUpliftMutation: uplift.requestChangesUpliftMutation,
addCommentMutation, addCommentMutation,
}; };
} }

View file

@ -1,23 +1,25 @@
import { useState } from "react"; import { useState } from "react";
import { import {
useApproveUplift, useApproveUplift,
useDenyUplift, useRejectUplift,
useRequestChangesUplift,
useUpliftsForDispatch, useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions"; } from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
type UpliftAction = { type: "approve" | "deny"; uplift: UpliftRequest }; type UpliftAction = { type: "approve" | "reject" | "request-changes"; uplift: UpliftRequest };
/** /**
* Owns the uplift approve/deny decision workflow for a dispatch: the pending * Owns the uplift decision workflow for a dispatch: the pending uplifts list,
* uplifts list, which uplift (if any) the user is currently deciding on, and * which uplift (if any) the user is currently deciding on, and the mutations
* the mutations backing that decision. * backing that decision.
*/ */
export function useDispatchUpliftController(dispatchId: string | number) { export function useDispatchUpliftController(dispatchId: string | number) {
const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId); const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
const [upliftAction, setUpliftAction] = useState<UpliftAction | null>(null); const [upliftAction, setUpliftAction] = useState<UpliftAction | null>(null);
const approveUpliftMutation = useApproveUplift(dispatchId); const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId); const rejectUpliftMutation = useRejectUplift(dispatchId);
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
const handleUpliftConfirm = (note: string) => { const handleUpliftConfirm = (note: string) => {
if (!upliftAction) { if (!upliftAction) {
@ -32,7 +34,15 @@ export function useDispatchUpliftController(dispatchId: string | number) {
return; return;
} }
denyUpliftMutation.mutate( if (upliftAction.type === "reject") {
rejectUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
return;
}
requestChangesUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note }, { id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) }, { onSuccess: () => setUpliftAction(null) },
); );
@ -44,8 +54,12 @@ export function useDispatchUpliftController(dispatchId: string | number) {
upliftAction, upliftAction,
setUpliftAction, setUpliftAction,
approveUpliftMutation, approveUpliftMutation,
denyUpliftMutation, rejectUpliftMutation,
requestChangesUpliftMutation,
handleUpliftConfirm, handleUpliftConfirm,
isMutating: approveUpliftMutation.isPending || denyUpliftMutation.isPending, isMutating:
approveUpliftMutation.isPending ||
rejectUpliftMutation.isPending ||
requestChangesUpliftMutation.isPending,
}; };
} }

View file

@ -19,6 +19,12 @@ type SessionUploadRequest = {
signal: AbortSignal; signal: AbortSignal;
}; };
function revokeBlobUrl(url: string | undefined) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function toLocalUpload(request: SessionUploadRequest): LocalUpload { function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file); const classified = classifyUpload(request.file);
return { return {
@ -39,10 +45,33 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal; return item.id === request.localId && item.signal === request.signal;
} }
function revokeBlobUrl(url: string) { function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] {
if (url.startsWith("blob:")) { const next = [...previous];
URL.revokeObjectURL(url); for (const entry of entries) {
const index = next.findIndex((item) => item.id === entry.id);
if (index >= 0) {
revokeBlobUrl(next[index]?.url);
next[index] = entry;
} else {
next.push(entry);
}
} }
return next;
}
function dropUploads(
previous: LocalUpload[],
matches: (item: LocalUpload) => boolean,
): LocalUpload[] {
const kept: LocalUpload[] = [];
for (const item of previous) {
if (matches(item)) {
revokeBlobUrl(item.url);
continue;
}
kept.push(item);
}
return kept;
} }
export function useMediaUploadSession( export function useMediaUploadSession(
@ -66,12 +95,7 @@ export function useMediaUploadSession(
controller.abort(); controller.abort();
} }
controllersRef.current.clear(); controllersRef.current.clear();
setLocalUploads((prev) => { setLocalUploads((prev) => dropUploads(prev, () => true));
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
}, [workOrderId]); }, [workOrderId]);
const serverUploads = useMemo( const serverUploads = useMemo(
@ -98,6 +122,27 @@ export function useMediaUploadSession(
return controller; return controller;
} }
function settleAttempt(request: SessionUploadRequest) {
clearController(request.localId);
setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request)));
}
function failAttempt(request: SessionUploadRequest, error: Error) {
// Keep the controller entry while the attempt is retryable; retry replaces it.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
}
function startUploads(requests: MediaUploadRequest[]) { function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({ const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId, localId: request.localId,
@ -105,23 +150,8 @@ export function useMediaUploadSession(
signal: replaceController(request.localId).signal, signal: replaceController(request.localId).signal,
})); }));
setLocalUploads((prev) => { const entries = sessionRequests.map(toLocalUpload);
const next = [...prev]; setLocalUploads((prev) => upsertUploads(prev, entries));
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
});
for (const request of sessionRequests) { for (const request of sessionRequests) {
void uploadMutateAsync({ void uploadMutateAsync({
@ -135,29 +165,13 @@ export function useMediaUploadSession(
); );
}, },
}) })
.then(() => { .then(() => settleAttempt(request))
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.catch((error: Error) => { .catch((error: Error) => {
if (error.name === "AbortError") { if (error.name === "AbortError") {
clearController(request.localId); settleAttempt(request);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return; return;
} }
// Keep controller entry only while retryable; replaceController on retry. failAttempt(request, error);
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
}); });
} }
} }
@ -174,21 +188,19 @@ export function useMediaUploadSession(
]); ]);
} }
function cancelUpload(localId: string) { /** Abort any in-flight attempt for the tile and drop it from the session. */
function dropLocal(localId: string) {
controllersRef.current.get(localId)?.abort(); controllersRef.current.get(localId)?.abort();
clearController(localId); clearController(localId);
setLocalUploads((prev) => { setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
const target = prev.find((item) => item.id === localId); }
if (target) {
revokeBlobUrl(target.url); function cancelUpload(localId: string) {
} dropLocal(localId);
return prev.filter((item) => item.id !== localId);
});
} }
function removeLocal(id: string) { function removeLocal(id: string) {
clearController(id); dropLocal(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
} }
function isLocal(id: string) { function isLocal(id: string) {

View file

@ -1,96 +0,0 @@
import { useMemo, useState } from "react";
import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
import {
useWorkOrderBoardDetail,
useWorkOrderDetail,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
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 { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
export function useWorkOrderViewController(workOrderId: string) {
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: boardDetail, isSuccess: isBoardDetailSuccess } =
useWorkOrderBoardDetail(workOrderId);
/** Fail closed until board detail succeeds — legacy GetById lacks type/doc/media. */
const isClosabilityReady = isBoardDetailSuccess && boardDetail != null;
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
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 wo = useMemo(
() => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined),
[detail, boardDetail],
);
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 handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
if (activeTab !== "customer" && activeTab !== "internal") {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
return {
wo,
isLoading,
isClosabilityReady,
error,
refetch,
users,
statusMutation,
assignmentMutation,
commentMutation,
vendorEditor,
activeTab,
setActiveTab,
commentText,
setCommentText,
dispatchOpen,
setDispatchOpen,
dispatchDetailId,
setDispatchDetailId,
statusAnchor,
setStatusAnchor,
assignAnchor,
setAssignAnchor,
customerComments,
internalComments,
auditLog,
handleCommentSubmit,
};
}
export type WorkOrderViewController = ReturnType<typeof useWorkOrderViewController>;

View file

@ -0,0 +1,163 @@
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 {
useWorkOrderBoardDetail,
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";
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
export function useWorkOrderViewPage(workOrderId: string) {
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: boardDetail, isSuccess: isBoardDetailSuccess } =
useWorkOrderBoardDetail(workOrderId);
/** Fail closed until board detail succeeds — legacy GetById lacks type/doc/media. */
const isClosabilityReady = isBoardDetailSuccess && boardDetail != null;
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 wo = useMemo(
() => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined),
[detail, boardDetail],
);
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,
isClosabilityReady,
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,
};
}

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