chore(work-orders): merge origin/dev into SH-165 Unassigned pin
22
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
91
docs/adr/0001-query-broadcast-client.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# 0001. Cross-tab QueryClient sync via `@tanstack/query-broadcast-client-experimental`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The SeaHaven admin SPA is frequently used with multiple browser tabs open
|
||||||
|
against the same work-order board (e.g. a dispatcher triaging the board in one
|
||||||
|
tab while editing a work order in another). Each tab owns its own TanStack
|
||||||
|
Query `QueryClient` cache, so a mutation performed in one tab (status change,
|
||||||
|
dispatch creation, comment, vendor patch, etc.) does not invalidate or update
|
||||||
|
the cache in sibling tabs. Users were seeing stale board/detail data until a
|
||||||
|
manual refresh or the next background refetch.
|
||||||
|
|
||||||
|
We need a way to keep `QueryClient` caches roughly in sync across tabs of the
|
||||||
|
same origin, without introducing a new state-management layer (Redux is
|
||||||
|
disallowed by this repo's conventions) or a server-push mechanism.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use TanStack's own experimental broadcast client,
|
||||||
|
[`@tanstack/query-broadcast-client-experimental`](https://tanstack.com/query),
|
||||||
|
wired up in a single dedicated setup module,
|
||||||
|
`src/lib/query/setup-query-broadcast.ts`. The module:
|
||||||
|
|
||||||
|
- Exposes `startQueryBroadcast(queryClient, userId)` and
|
||||||
|
`stopQueryBroadcast(queryClient)`.
|
||||||
|
- Keys `broadcastQueryClient` to an **account-scoped** channel
|
||||||
|
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
|
||||||
|
on the same origin do not share cache traffic.
|
||||||
|
- Disposes the prior subscription (the unsubscribe returned by
|
||||||
|
`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
|
||||||
|
the library that owns the `QueryClient` we already depend on, and keeps the
|
||||||
|
integration isolated to auth/session boundaries.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Custom `BroadcastChannel` + manual `queryClient.invalidateQueries` calls**
|
||||||
|
— full control over payloads, but requires hand-rolling
|
||||||
|
serialization/versioning of query keys and mutation results, and keeping
|
||||||
|
every future mutation hook wired to broadcast. More code to own and more
|
||||||
|
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
|
||||||
|
unresolved; users would need to manually refresh or wait for
|
||||||
|
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
||||||
|
for a live dispatch board.
|
||||||
|
- **Full WebSocket-based real-time sync** — solves both cross-tab and
|
||||||
|
cross-user staleness, but is a materially larger investment (server-side
|
||||||
|
push infra, connection lifecycle, auth over the socket) that is out of scope
|
||||||
|
for the current admin SPA and not justified by the actual problem (same
|
||||||
|
browser, same user, same origin).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive**: sibling tabs for the same authenticated account reflect
|
||||||
|
mutations (status changes, dispatch actions, comments, patches) without a
|
||||||
|
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
|
||||||
|
the TanStack ecosystem; its API may change or be deprecated between minor
|
||||||
|
versions. `@tanstack/query-broadcast-client-experimental`,
|
||||||
|
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
||||||
|
the **exact same version** in `package.json` (no `^` range). All three
|
||||||
|
resolve to the identical `@tanstack/query-core` version at that pin, which
|
||||||
|
keeps the `QueryClient` type used by the broadcast helpers structurally
|
||||||
|
identical to the one constructed in `query-client.ts` — a caret range lets
|
||||||
|
npm resolve the broadcast client and React Query against two different
|
||||||
|
`query-core` versions independently, which breaks `QueryClient` type
|
||||||
|
identity (TS2322) even though both packages build fine in isolation. Bump
|
||||||
|
all three together and re-evaluate on every TanStack Query upgrade.
|
||||||
|
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
||||||
|
origin; it does not sync across different users/sessions or devices. Account
|
||||||
|
scoping further limits sync to tabs of the same `userId`.
|
||||||
|
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
||||||
|
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
|
||||||
|
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
|
||||||
|
`api.ts`. No other code depends on it, so rollback is isolated with no data
|
||||||
|
migration.
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# Pending BE contract (WO design parity gaps)
|
|
||||||
|
|
||||||
Local checklist for FE branches off `dev`. Confirm with backend before shipping PR3/PR5 to production. Until confirmed, FE may use typed clients + mocks.
|
|
||||||
|
|
||||||
## Board row / PATCH
|
|
||||||
|
|
||||||
| Field | Needed by | Notes |
|
|
||||||
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date |
|
|
||||||
| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` |
|
|
||||||
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` |
|
|
||||||
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
|
|
||||||
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
|
|
||||||
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
|
|
||||||
|
|
||||||
## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue)
|
|
||||||
|
|
||||||
| Method | Path | Body |
|
|
||||||
| ------ | ------------------------------------------- | --------------------------------- |
|
|
||||||
| GET | `workorders/{id}/uplifts` | — |
|
|
||||||
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` |
|
|
||||||
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — |
|
|
||||||
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` |
|
|
||||||
|
|
||||||
Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`.
|
|
||||||
|
|
||||||
## Search query params (`GET workorders/board/search`)
|
|
||||||
|
|
||||||
| Param | Meaning |
|
|
||||||
| ------------------------------ | ------------------------- |
|
|
||||||
| `severities` | multi `1`–`5` |
|
|
||||||
| `rescheduled` / `carriedOver` | boolean (≥2 counters) |
|
|
||||||
| `addOn` | boolean (`isAddOn`) |
|
|
||||||
| `flagColors` | multi `#RRGGBB` |
|
|
||||||
| `internalOnly` | WO# starts with `SH` |
|
|
||||||
| `avetaOnly` | if `avetaRequired` exists |
|
|
||||||
| `hasUplift` + `upliftStatuses` | uplift filters |
|
|
||||||
|
|
||||||
Already documented in `board-search-api.md`: datePreset, sites, types, overdue, dispatchers, statuses, pmTypes, vendorIds, docStatuses.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
| Field | Needed by |
|
|
||||||
| ---------------------------------------- | --------- |
|
|
||||||
| POST `{ text, mentions?: string[] }` | PR9 |
|
|
||||||
| Response includes mentions for highlight | PR9 |
|
|
||||||
|
|
||||||
## Vendor
|
|
||||||
|
|
||||||
| Item | Needed by |
|
|
||||||
| -------------------------------------------------------------- | --------- |
|
|
||||||
| Create technician under company (confirm existing vendors API) | PR7 |
|
|
||||||
|
|
||||||
## Optional
|
|
||||||
|
|
||||||
| Item | Needed by |
|
|
||||||
| ----------------------------------- | -------------------------------- |
|
|
||||||
| 422 on duplicate WO number (global) | PR8 (FE dialog works without it) |
|
|
||||||
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
vendored
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
vendored
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
221
e2e/vendor-portal/portal-lifecycle.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
356
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
Normal 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." },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
33
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -556,6 +556,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 },
|
||||||
|
|
@ -569,4 +583,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
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
130
package-lock.json
generated
|
|
@ -21,8 +21,9 @@
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||||
"@tanstack/react-query-devtools": "^5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"@tanstack/react-query-devtools": "5.101.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|
@ -2332,6 +2333,20 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-broadcast-client-experimental": {
|
||||||
|
"version": "5.101.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz",
|
||||||
|
"integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.101.2",
|
||||||
|
"broadcast-channel": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.2",
|
"version": "5.101.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
||||||
|
|
@ -3214,6 +3229,30 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/broadcast-channel": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "7.28.6",
|
||||||
|
"oblivious-set": "2.0.0",
|
||||||
|
"p-queue": "6.6.2",
|
||||||
|
"unload": "2.4.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/pubkey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/broadcast-channel/node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.6",
|
"version": "4.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||||
|
|
@ -4048,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": [
|
||||||
{
|
{
|
||||||
|
|
@ -4578,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": [
|
||||||
{
|
{
|
||||||
|
|
@ -5302,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",
|
||||||
|
|
@ -5345,6 +5384,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oblivious-set": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
|
@ -5393,6 +5441,15 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-finally": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
|
|
@ -5425,6 +5482,40 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-queue": {
|
||||||
|
"version": "6.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
|
||||||
|
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.4",
|
||||||
|
"p-timeout": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-queue/node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/p-timeout": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-finally": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -5561,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",
|
||||||
|
|
@ -5580,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"
|
||||||
},
|
},
|
||||||
|
|
@ -6401,6 +6492,15 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unload": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/pubkey"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -54,8 +55,9 @@
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||||
"@tanstack/react-query-devtools": "^5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"@tanstack/react-query-devtools": "5.101.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
43
playwright.visual.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -2,16 +2,5 @@
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||||
"maxFileLines": 500,
|
"maxFileLines": 500,
|
||||||
"godfileDebt": [
|
"godfileDebt": []
|
||||||
{
|
|
||||||
"path": "src/app/(protected)/workorders/[id].tsx",
|
|
||||||
"maxLines": 797,
|
|
||||||
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/app/(protected)/vendors/index.tsx",
|
|
||||||
"maxLines": 612,
|
|
||||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +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 { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
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");
|
||||||
|
|
@ -48,7 +47,7 @@ export const api: KyInstance = ky.create({
|
||||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||||
afterResponse: [
|
afterResponse: [
|
||||||
({ request, response }) => {
|
({ request, response }) => {
|
||||||
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
|
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx
vendored
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx
vendored
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||||
|
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||||
import { VendorFilterOptions } from "./vendor-filter-options";
|
import { VendorFilterOptions } from "./vendor-filter-options";
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
interface VendorFiltersDrawerProps {
|
interface VendorFiltersDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -25,12 +25,18 @@ export function VendorFiltersDrawer({
|
||||||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setDraft(appliedFilters);
|
if (open) {
|
||||||
|
setDraft(appliedFilters);
|
||||||
|
}
|
||||||
}, [open, appliedFilters]);
|
}, [open, appliedFilters]);
|
||||||
|
|
||||||
const activeCount =
|
const activeCount =
|
||||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setDraft(emptyVendorFilters);
|
||||||
|
};
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply(draft);
|
onApply(draft);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -65,7 +71,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" />
|
||||||
|
|
@ -85,12 +91,7 @@ export function VendorFiltersDrawer({
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setDraft(emptyVendorFilters)}
|
|
||||||
disabled={activeCount === 0}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Clear all
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,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;
|
||||||
|
|
@ -59,26 +90,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
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|
@ -87,6 +106,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}>
|
||||||
|
|
@ -107,7 +127,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
{...companySelectionProps}
|
{...companySelectionProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEdit && vendorId !== undefined && vendorId !== "" && (
|
{showPortalToken && vendorId !== undefined && (
|
||||||
<VendorPortalTokenPanel vendorId={vendorId} />
|
<VendorPortalTokenPanel vendorId={vendorId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -120,7 +140,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>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export function VendorsListToolbar({
|
||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
placeholder="Search name, company, trade…"
|
placeholder="Search name, company, trade…"
|
||||||
aria-label="Search vendors"
|
aria-label="Search vendors"
|
||||||
|
fullWidth
|
||||||
|
className="w-full md:w-56"
|
||||||
inputClassName="h-8 py-0 text-[13px]"
|
inputClassName="h-8 py-0 text-[13px]"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|
@ -49,7 +51,7 @@ export function VendorsListToolbar({
|
||||||
onClick={onOpenFilters}
|
onClick={onOpenFilters}
|
||||||
aria-label="Open vendor filters"
|
aria-label="Open vendor filters"
|
||||||
badge={appliedFilterCount}
|
badge={appliedFilterCount}
|
||||||
className="w-[88.609375px] text-[13px]"
|
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
|
||||||
>
|
>
|
||||||
Filters
|
Filters
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
@ -57,7 +59,7 @@ export function VendorsListToolbar({
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Plus size={16} />}
|
icon={<Plus size={16} />}
|
||||||
onClick={onAddVendor}
|
onClick={onAddVendor}
|
||||||
className="w-[115.546875px] border-0 px-3 text-[13px]"
|
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
|
||||||
>
|
>
|
||||||
Add Vendor
|
Add Vendor
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,100 @@
|
||||||
import { useState } from "react";
|
import { useParams } from "react-router";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { Box } from "@mui/material";
|
||||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
|
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 { VendorEditorDialog } from "./_components/vendor-editor-dialog";
|
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
|
||||||
import { useVendorEditor } from "./_components/use-vendor-editor";
|
import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion";
|
||||||
import { WorkOrderActivity } from "./_components/work-order-activity";
|
import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog";
|
||||||
import {
|
import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions";
|
||||||
DispatchAccordion,
|
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||||
WorkOrderDetailsSection,
|
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||||
WorkOrderHeader,
|
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||||
WorkOrderSummaryAccordions,
|
|
||||||
} from "./_components/work-order-view-sections";
|
|
||||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import {
|
|
||||||
useChangeWorkOrderAssignment,
|
|
||||||
useChangeWorkOrderStatus,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
||||||
|
|
||||||
export default function WorkOrderViewPage() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
|
const view = useWorkOrderViewPage(workOrderId);
|
||||||
|
|
||||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
if (view.isLoading && !view.wo) {
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
return <DetailPageSkeleton variant="view" />;
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
|
||||||
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
|
|
||||||
|
|
||||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
|
||||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="flex justify-center p-12">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !wo) {
|
if (!view.wo) {
|
||||||
return (
|
return (
|
||||||
<Box className="p-4">
|
<Box className="p-4">
|
||||||
<Alert severity="error">
|
<QueryErrorPanel
|
||||||
{error instanceof Error ? error.message : "Work order not found."}
|
message={view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||||
</Alert>
|
onRetry={() => void view.refetch()}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<WorkOrderHeader
|
<WorkOrderViewHeader
|
||||||
wo={wo}
|
wo={view.wo}
|
||||||
onSelectStatus={(status) => statusMutation.mutate(status)}
|
|
||||||
onDispatch={() => setDispatchOpen(true)}
|
|
||||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
|
||||||
onBack={() => navigate("/workorders")}
|
|
||||||
/>
|
|
||||||
<WorkOrderDetailsSection
|
|
||||||
wo={wo}
|
|
||||||
users={users}
|
|
||||||
onAssign={(payload) => assignmentMutation.mutate(payload)}
|
|
||||||
/>
|
|
||||||
<WorkOrderSummaryAccordions wo={wo} />
|
|
||||||
<DispatchAccordion
|
|
||||||
wo={wo}
|
|
||||||
onEditVendor={(event, vendorId, vendorName) =>
|
|
||||||
void vendorEditor.open(event, vendorId, vendorName)
|
|
||||||
}
|
|
||||||
onViewDispatch={setDispatchDetailId}
|
|
||||||
/>
|
|
||||||
<WorkOrderActivity
|
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
comments={wo.comments ?? []}
|
statusAnchor={view.statusAnchor}
|
||||||
auditLog={wo.auditLog ?? []}
|
onStatusAnchorChange={view.setStatusAnchor}
|
||||||
|
onStatusChange={(status) => view.statusMutation.mutate(status)}
|
||||||
|
onOpenDispatch={() => view.setDispatchOpen(true)}
|
||||||
/>
|
/>
|
||||||
{dispatchOpen && (
|
|
||||||
|
<WorkOrderViewDetails
|
||||||
|
wo={view.wo}
|
||||||
|
users={view.users}
|
||||||
|
assignAnchor={view.assignAnchor}
|
||||||
|
onAssignAnchorChange={view.setAssignAnchor}
|
||||||
|
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
|
||||||
|
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderViewAccordions wo={view.wo} />
|
||||||
|
|
||||||
|
<WorkOrderDispatchesAccordion
|
||||||
|
wo={view.wo}
|
||||||
|
onOpenDispatchDetail={view.setDispatchDetailId}
|
||||||
|
onOpenVendorEditor={view.openVendorEditor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderActivityPanel
|
||||||
|
customerComments={view.customerComments}
|
||||||
|
internalComments={view.internalComments}
|
||||||
|
auditLog={view.auditLog}
|
||||||
|
mentionUsers={view.mentionUsers}
|
||||||
|
isSubmitting={view.commentMutation.isPending}
|
||||||
|
onSubmitComment={view.submitComment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{view.dispatchOpen && (
|
||||||
<DispatchCreateModal
|
<DispatchCreateModal
|
||||||
workOrder={wo}
|
workOrder={view.wo}
|
||||||
onClose={() => setDispatchOpen(false)}
|
onClose={() => view.setDispatchOpen(false)}
|
||||||
onDispatched={() => void refetch()}
|
onDispatched={() => void view.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dispatchDetailId != null && (
|
{view.dispatchDetailId != null && (
|
||||||
<DispatchDetailModal
|
<DispatchDetailModal
|
||||||
dispatchId={dispatchDetailId}
|
dispatchId={view.dispatchDetailId}
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
onClose={() => setDispatchDetailId(null)}
|
onClose={() => view.setDispatchDetailId(null)}
|
||||||
onUpdated={() => void refetch()}
|
onUpdated={() => void view.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<VendorEditorDialog
|
|
||||||
editor={vendorEditor.editor}
|
<WorkOrderVendorEditorDialog
|
||||||
loading={vendorEditor.loading}
|
editor={view.vendorEditor}
|
||||||
error={vendorEditor.error}
|
loading={view.vendorEditorLoading}
|
||||||
isPending={vendorEditor.isPending}
|
error={view.vendorEditorError}
|
||||||
phoneInvalid={vendorEditor.phoneInvalid}
|
isSaving={view.vendorMutation.isPending}
|
||||||
isValid={vendorEditor.isValid}
|
onClose={() => {
|
||||||
onClose={vendorEditor.close}
|
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
||||||
onSave={vendorEditor.save}
|
}}
|
||||||
onFieldChange={vendorEditor.updateField}
|
onSave={view.saveVendorEditor}
|
||||||
|
onEditorChange={view.setVendorEditor}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
type CompDocCoLineProps = {
|
||||||
|
icon: ElementType;
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-1.5 break-words"
|
||||||
|
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
|
||||||
|
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
type CompDocDetailRowProps = {
|
||||||
|
icon: ElementType;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
last?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2.5"
|
||||||
|
style={{
|
||||||
|
padding: "10px 0",
|
||||||
|
borderBottom: last ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={15}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Ban, Save, Sparkles, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterDraftActionsProps = {
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onMarkNotRequired: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterDraftActions({
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
pdfInputRef,
|
||||||
|
onMarkNotRequired,
|
||||||
|
onSaveDraft,
|
||||||
|
onGenerate,
|
||||||
|
}: CompDocDialogFooterDraftActionsProps) {
|
||||||
|
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)" }}
|
||||||
|
disabled={isUploading}
|
||||||
|
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}
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
disabled={!rowVersion || isUploading}
|
||||||
|
loading={isUploading}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||||
|
>
|
||||||
|
Upload PDF
|
||||||
|
</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" }}
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={onGenerate}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { CheckCircle2, Download, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterGeneratedActionsProps = {
|
||||||
|
generatedAt: string;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onDownloadPdf: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterGeneratedActions({
|
||||||
|
generatedAt,
|
||||||
|
signOffAttachment,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
pdfInputRef,
|
||||||
|
onDownloadPdf,
|
||||||
|
}: CompDocDialogFooterGeneratedActionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} />{" "}
|
||||||
|
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{!isDocumentsReadOnly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
disabled={!rowVersion}
|
||||||
|
loading={isUploading}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||||
|
>
|
||||||
|
Replace PDF
|
||||||
|
</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={onDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Ban, Sparkles, Undo2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterNotRequiredActionsProps = {
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterNotRequiredActions({
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
onUndo,
|
||||||
|
}: CompDocDialogFooterNotRequiredActionsProps) {
|
||||||
|
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">
|
||||||
|
{!isDocumentsReadOnly && (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
|
||||||
|
import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
|
||||||
|
import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
|
||||||
|
|
||||||
|
type CompDocDialogFooterProps = {
|
||||||
|
docState: DocState;
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
generatedAt: string;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onMarkNotRequired: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onDownloadPdf: () => void;
|
||||||
|
onUploadPdf: (files: FileList | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooter({
|
||||||
|
docState,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
signOffAttachment,
|
||||||
|
generatedAt,
|
||||||
|
pdfInputRef,
|
||||||
|
onMarkNotRequired,
|
||||||
|
onSaveDraft,
|
||||||
|
onGenerate,
|
||||||
|
onUndo,
|
||||||
|
onDownloadPdf,
|
||||||
|
onUploadPdf,
|
||||||
|
}: CompDocDialogFooterProps) {
|
||||||
|
// Exclusive footer branches (prototype: one docState at a time). A signed PDF
|
||||||
|
// on file implies Generated even if local docState has not caught up yet — never
|
||||||
|
// render Draft actions alongside Generated.
|
||||||
|
const showNotRequired = docState === "not-required";
|
||||||
|
const showGenerated =
|
||||||
|
!showNotRequired && (docState === "generated" || Boolean(signOffAttachment));
|
||||||
|
const showDraft = !showNotRequired && !showGenerated && docState === "draft";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
onUploadPdf(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Boolean(showDraft && !isDocumentsReadOnly) && (
|
||||||
|
<CompDocDialogFooterDraftActions
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onMarkNotRequired={onMarkNotRequired}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showGenerated && (
|
||||||
|
<CompDocDialogFooterGeneratedActions
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
signOffAttachment={signOffAttachment}
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onDownloadPdf={onDownloadPdf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showNotRequired && (
|
||||||
|
<CompDocDialogFooterNotRequiredActions
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
onUndo={onUndo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { FileText, X } from "lucide-react";
|
||||||
|
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
|
||||||
|
type CompDocDialogHeaderProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
cfg: CompletionDocStateConfig;
|
||||||
|
isUploading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
padding: "0 20px",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completion Document
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO{" "}
|
||||||
|
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||||
|
#{row.woNumber}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{serviceTitle(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillBg,
|
||||||
|
color: cfg.pillText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.pillLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isUploading}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
type CompDocDialogResourceLinksProps = {
|
||||||
|
templateUrl?: string | null;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogResourceLinks({
|
||||||
|
templateUrl,
|
||||||
|
signOffAttachment,
|
||||||
|
}: CompDocDialogResourceLinksProps) {
|
||||||
|
if (!templateUrl && !signOffAttachment) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Boolean(templateUrl) && (
|
||||||
|
<a
|
||||||
|
href={templateUrl ?? undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Download blank template
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{Boolean(signOffAttachment) && (
|
||||||
|
<a
|
||||||
|
href={signOffAttachment ?? undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
Open uploaded PDF
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useRef, useState, type MouseEvent } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { 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 { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
||||||
|
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
|
||||||
|
import { CompDocPreview } from "./comp-doc-preview";
|
||||||
|
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||||
|
|
||||||
|
type CompDocDialogProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocDialogProps) {
|
||||||
|
const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
|
||||||
|
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||||
|
|
||||||
|
const templateUrl = boardDetail?.completion.template?.templateUrl;
|
||||||
|
const signOffAttachment = boardDetail?.completion.signOffAttachment || null;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||||
|
style: {
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "min(1024px, calc(100vw - 32px))",
|
||||||
|
maxWidth: "none",
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
dividers={false}
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||||
|
<Text className="sr-only" variant="description">
|
||||||
|
Generate, save or mark not-required the completion document for this work order.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<CompDocDialogHeader
|
||||||
|
row={row}
|
||||||
|
cfg={dialog.cfg}
|
||||||
|
isUploading={upload.isUploading}
|
||||||
|
onClose={() => handleClose?.()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CompDocDialogResourceLinks
|
||||||
|
templateUrl={templateUrl}
|
||||||
|
signOffAttachment={signOffAttachment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
|
<CompDocPreview
|
||||||
|
row={row}
|
||||||
|
site={dialog.site}
|
||||||
|
notes={dialog.notes}
|
||||||
|
generatedAt={dialog.generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
isNotRequired={dialog.isNotRequired}
|
||||||
|
/>
|
||||||
|
<CompDocSidebar
|
||||||
|
row={row}
|
||||||
|
site={dialog.site}
|
||||||
|
docState={dialog.docState}
|
||||||
|
generatedAt={dialog.generatedAt}
|
||||||
|
notes={dialog.notes}
|
||||||
|
editAddress={dialog.editAddress}
|
||||||
|
onAddressChange={dialog.setEditAddress}
|
||||||
|
editDateCompleted={dialog.editDateCompleted}
|
||||||
|
amazonRep={dialog.amazonRep}
|
||||||
|
seaRep={dialog.seaRep}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
onNotesChange={dialog.setNotes}
|
||||||
|
onDateCompletedChange={dialog.setEditDateCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompDocDialogFooter
|
||||||
|
docState={dialog.docState}
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={upload.isUploading}
|
||||||
|
signOffAttachment={signOffAttachment}
|
||||||
|
generatedAt={dialog.generatedAt}
|
||||||
|
pdfInputRef={upload.pdfInputRef}
|
||||||
|
onMarkNotRequired={dialog.handleMarkNotRequired}
|
||||||
|
onSaveDraft={dialog.handleSaveDraft}
|
||||||
|
onGenerate={dialog.handleGenerate}
|
||||||
|
onUndo={dialog.handleUndo}
|
||||||
|
onDownloadPdf={handleDownloadPdf}
|
||||||
|
onUploadPdf={upload.handleUploadPdf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
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)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompDocEditRowProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
type?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocEditRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
type = "text",
|
||||||
|
readOnly = false,
|
||||||
|
}: CompDocEditRowProps) {
|
||||||
|
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={readOnly}
|
||||||
|
className={COMP_DOC_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...COMP_DOC_INPUT_STYLE,
|
||||||
|
...(readOnly
|
||||||
|
? {
|
||||||
|
opacity: 0.6,
|
||||||
|
cursor: "default",
|
||||||
|
pointerEvents: "none" as const,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 CompDocPreviewCompanyHeader() {
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
|
||||||
|
import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
|
||||||
|
import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
|
||||||
|
import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
|
||||||
|
|
||||||
|
type CompDocPreviewDocumentProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewDocument({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewDocumentProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<CompDocPreviewCompanyHeader />
|
||||||
|
<CompDocPreviewServiceInfo
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
/>
|
||||||
|
<CompDocPreviewVendorSafety row={row} />
|
||||||
|
<CompDocPreviewProcedureNotes pm={row.pm} notes={notes} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Ban } from "lucide-react";
|
||||||
|
|
||||||
|
export function CompDocPreviewNotRequiredStamp() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
|
|
||||||
|
type CompDocPreviewProcedureNotesProps = {
|
||||||
|
pm: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
|
||||||
|
const proc = procedureFor(pm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.heading}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#58595B",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.intro}
|
||||||
|
</Text>
|
||||||
|
<CompDocProcedureList proc={proc} />
|
||||||
|
|
||||||
|
{Boolean(notes) && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
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 CompDocPreviewServiceInfoProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
isGenerated: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewServiceInfo({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
isGenerated,
|
||||||
|
generatedAt,
|
||||||
|
}: CompDocPreviewServiceInfoProps) {
|
||||||
|
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"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceTitle(row).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="font-mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1C75BC",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO# {row.woNumber}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site ID — {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||||
|
Amazon.com Services LLC {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.street},
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CheckCircle2, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
type CompDocPreviewStatusBadgeProps = {
|
||||||
|
isGenerated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: </span>
|
||||||
|
{row.tech || "—"}
|
||||||
|
{row.company ? ` · ${row.company}` : ""}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocPreviewVendorSafetyProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||||
|
{row.tech || "—"}
|
||||||
|
{row.company ? ` · ${row.company}` : ""}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
||||||
|
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
||||||
|
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
||||||
|
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
||||||
|
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
|
||||||
|
|
||||||
|
type CompDocPreviewProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreview({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "58%",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
style={{ padding: "0 20px 20px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative break-words"
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<CompDocPreviewHeader />
|
||||||
|
<CompDocPreviewSummary
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
/>
|
||||||
|
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocProcedureListProps = {
|
||||||
|
proc: Procedure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||||
|
return (
|
||||||
|
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||||
|
{proc.steps.map((st, i) => {
|
||||||
|
const subSteps = st.sub ?? [];
|
||||||
|
return (
|
||||||
|
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
{st.text}
|
||||||
|
{subSteps.length > 0 && (
|
||||||
|
<ol
|
||||||
|
style={{
|
||||||
|
listStyle: "lower-alpha",
|
||||||
|
paddingLeft: 22,
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subSteps.map((x, j) => (
|
||||||
|
<li key={j} style={{ margin: "2px 0" }}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { CompDocDetailsSection } from "./comp-doc-details-section";
|
||||||
|
import { CompDocNotesField } from "./comp-doc-notes-field";
|
||||||
|
import { CompDocStatusCard } from "./comp-doc-status-card";
|
||||||
|
|
||||||
|
type CompDocSidebarProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
docState: DocState;
|
||||||
|
generatedAt: string;
|
||||||
|
notes: string;
|
||||||
|
editAddress: string;
|
||||||
|
onAddressChange: (value: string) => void;
|
||||||
|
editDateCompleted: string;
|
||||||
|
amazonRep: string;
|
||||||
|
seaRep: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
onNotesChange: (value: string) => void;
|
||||||
|
onDateCompletedChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocSidebar({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
notes,
|
||||||
|
editAddress,
|
||||||
|
onAddressChange,
|
||||||
|
editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
isGenerated,
|
||||||
|
onNotesChange,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||||
|
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
|
||||||
|
<CompDocDetailsSection
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
editAddress={editAddress}
|
||||||
|
onAddressChange={onAddressChange}
|
||||||
|
editDateCompleted={editDateCompleted}
|
||||||
|
onDateCompletedChange={onDateCompletedChange}
|
||||||
|
amazonRep={amazonRep}
|
||||||
|
seaRep={seaRep}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
/>
|
||||||
|
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
|
||||||
|
type UseCompDocDialogSeedArgs = {
|
||||||
|
open: boolean;
|
||||||
|
infoId: string | number;
|
||||||
|
infoDocStatus: CompletionDocStatus;
|
||||||
|
infoCompletedDate?: string | null;
|
||||||
|
completionDocStatus?: CompletionDocStatus | null;
|
||||||
|
data?: CompDocData;
|
||||||
|
setNotes: Dispatch<SetStateAction<string>>;
|
||||||
|
setDocState: Dispatch<SetStateAction<DocState>>;
|
||||||
|
setGeneratedAt: Dispatch<SetStateAction<string>>;
|
||||||
|
setEditDateCompleted: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
|
||||||
|
export function useCompDocDialogSeed({
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
}: UseCompDocDialogSeedArgs) {
|
||||||
|
const seedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
seedKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = String(infoId);
|
||||||
|
if (seedKeyRef.current === key) return;
|
||||||
|
seedKeyRef.current = key;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(completionDocStatus || infoDocStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(infoCompletedDate || "");
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data?.notes,
|
||||||
|
data?.generatedAt,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||||
|
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
|
||||||
|
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
|
||||||
|
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||||
|
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
||||||
|
|
||||||
|
type UseCompDocDialogArgs = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function notifyDraftSaved() {
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draft saved
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: "bottom-center",
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
icon: false,
|
||||||
|
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompDocDialog({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: UseCompDocDialogArgs) {
|
||||||
|
const locId = String(row.locationId || "");
|
||||||
|
const { data: locationDetail } = useLocationDetail(locId || undefined);
|
||||||
|
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||||
|
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||||
|
|
||||||
|
const info = boardDetail?.info ?? row;
|
||||||
|
const completion = boardDetail?.completion;
|
||||||
|
const templateUrl = completion?.template?.templateUrl;
|
||||||
|
const signOffAttachment = completion?.signOffAttachment;
|
||||||
|
const rowVersion = info.rowVersion || row.rowVersion;
|
||||||
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
|
||||||
|
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [docState, setDocState] = useState<DocState>("draft");
|
||||||
|
const [generatedAt, setGeneratedAt] = useState("");
|
||||||
|
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||||
|
|
||||||
|
useCompDocDialogSeed({
|
||||||
|
open,
|
||||||
|
infoId: info.id,
|
||||||
|
infoDocStatus: info.docStatus,
|
||||||
|
infoCompletedDate: info.completedDate,
|
||||||
|
completionDocStatus: completion?.docStatus,
|
||||||
|
data,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const amazonRep = info.pocName || "";
|
||||||
|
const seaRep = info.tech || "";
|
||||||
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
|
const isNotRequired = docState === "not-required";
|
||||||
|
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
|
||||||
|
|
||||||
|
const docFields: DocFields = {
|
||||||
|
street: site.street,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
zip: site.zip,
|
||||||
|
dateCompleted: editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
|
||||||
|
useCompDocPersistFlow({
|
||||||
|
workOrderId: row.id,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
currentCompletedDate: info.completedDate || "",
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
onGenerated: (ts) => {
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
},
|
||||||
|
onDraftSaved: notifyDraftSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
|
||||||
|
docState,
|
||||||
|
setDocState,
|
||||||
|
persist,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
setIsFinalizing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated: () => setDocState("generated"),
|
||||||
|
onPersistFailure: refreshAuthoritativeState,
|
||||||
|
onFinalizingChange: setIsFinalizing,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
templateUrl,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
setNotes,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
setEditDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
cfg,
|
||||||
|
isNotRequired,
|
||||||
|
isGenerated,
|
||||||
|
isUploading: uploadCompletionMutation.isPending || isFinalizing,
|
||||||
|
pdfInputRef,
|
||||||
|
handleMarkNotRequired,
|
||||||
|
handleUndo,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
handleDownloadPdf,
|
||||||
|
handleUploadPdf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
|
||||||
|
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
|
||||||
|
type UseCompDocNotRequiredActionsArgs = {
|
||||||
|
docState: DocState;
|
||||||
|
setDocState: (state: DocState) => void;
|
||||||
|
persist: PersistFn;
|
||||||
|
refreshAuthoritativeState: () => void;
|
||||||
|
setIsFinalizing: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocNotRequiredActions({
|
||||||
|
docState,
|
||||||
|
setDocState,
|
||||||
|
persist,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
setIsFinalizing,
|
||||||
|
}: UseCompDocNotRequiredActionsArgs) {
|
||||||
|
async function handleMarkNotRequired() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("not-required");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "NN");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to mark document as not required",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("draft");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "No");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleMarkNotRequired, handleUndo };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocFields,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import {
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
type UseCompDocPdfActionsArgs = {
|
||||||
|
info: WorkOrderTableRow;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
rowVersion?: string;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
docFields: DocFields;
|
||||||
|
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
|
||||||
|
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => Promise<void>;
|
||||||
|
syncDateIfChanged: () => Promise<void>;
|
||||||
|
setGeneratedAt: (value: string) => void;
|
||||||
|
markGenerated: () => void;
|
||||||
|
onPersistFailure?: () => void;
|
||||||
|
onFinalizingChange?: (finalizing: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated,
|
||||||
|
onPersistFailure,
|
||||||
|
onFinalizingChange,
|
||||||
|
}: UseCompDocPdfActionsArgs) {
|
||||||
|
function handleDownloadPdf() {
|
||||||
|
if (signOffAttachment) {
|
||||||
|
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadPdf(files: FileList | null) {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
toast.error("Please upload a PDF file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rowVersion) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCompletionMutation.mutate(
|
||||||
|
{ file, workOrderVersion: rowVersion },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Keep close disabled across the gap between upload settle and docStatus persist.
|
||||||
|
onFinalizingChange?.(true);
|
||||||
|
void (async () => {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
try {
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
markGenerated();
|
||||||
|
} catch (error) {
|
||||||
|
onPersistFailure?.();
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to finalize completion document",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
onFinalizingChange?.(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleDownloadPdf, handleUploadPdf };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
type UseCompDocPersistFlowArgs = {
|
||||||
|
workOrderId: string | number;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
editDateCompleted: string;
|
||||||
|
currentCompletedDate: string;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
onGenerated: (generatedAt: string) => void;
|
||||||
|
onDraftSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPersistFlow({
|
||||||
|
workOrderId,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
currentCompletedDate,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
onGenerated,
|
||||||
|
onDraftSaved,
|
||||||
|
}: UseCompDocPersistFlowArgs) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const refreshAuthoritativeState = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.detail(workOrderId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
await onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDateIfChanged = async () => {
|
||||||
|
if (editDateCompleted !== currentCompletedDate) {
|
||||||
|
await onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSaveDraft() {
|
||||||
|
try {
|
||||||
|
// Prerequisites first; docStatus last.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({}, "No");
|
||||||
|
onDraftSaved();
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
try {
|
||||||
|
// Prerequisites first; only mark Generated after completedDate persists.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
onGenerated(ts);
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type AuditTimelineProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
emptyMessage?: string;
|
||||||
|
maxHeightClass?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuditTimeline({
|
||||||
|
entries,
|
||||||
|
emptyMessage = "No changes recorded.",
|
||||||
|
maxHeightClass = "max-h-72",
|
||||||
|
}: AuditTimelineProps) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...entries].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return <Typography color="text.secondary">{emptyMessage}</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={`${maxHeightClass} space-y-3 overflow-y-auto`}>
|
||||||
|
{sorted.map((entry) => (
|
||||||
|
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||||
|
<Box className="mb-1 flex justify-between gap-2">
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{entry.userName || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{entry.action ? (
|
||||||
|
entry.action
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(entry.action && entry.fieldName) && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
className="mt-1"
|
||||||
|
sx={{ display: "block" }}
|
||||||
|
>
|
||||||
|
{entry.fieldName}
|
||||||
|
{entry.oldValue || entry.newValue
|
||||||
|
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
|
||||||
|
: ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import {
|
||||||
|
getAvatarColorFromName,
|
||||||
|
getDispatcherAvatarColor,
|
||||||
|
getInitialsFromName,
|
||||||
|
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||||
|
|
||||||
|
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||||
|
if (entry.type === "system") return true;
|
||||||
|
if (entry.type === "manual") return false;
|
||||||
|
const name = entry.userName?.trim();
|
||||||
|
return !name || name.toLowerCase() === "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual (image): `· 9:48 AM`
|
||||||
|
* System (image): `05/13 · 8:00 AM`
|
||||||
|
* Prefer API display strings that already include · / AM|PM.
|
||||||
|
*/
|
||||||
|
function formatAuditTime(value: string, isSystem: boolean): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
|
||||||
|
const timePart = formatClock(date);
|
||||||
|
if (isSystem) {
|
||||||
|
const datePart = date.toLocaleDateString("en-US", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
return `${datePart} · ${timePart}`;
|
||||||
|
}
|
||||||
|
return `· ${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverAuditListProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
users?: WorkOrderUser[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mb-3 text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Audit Log
|
||||||
|
</Text>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="py-4 text-center text-[12px]"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
No changes recorded
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, index) => {
|
||||||
|
const isSys = isSystemEntry(entry);
|
||||||
|
const dispatcher = users.find(
|
||||||
|
(user) =>
|
||||||
|
entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
|
||||||
|
);
|
||||||
|
const name =
|
||||||
|
dispatcher?.name ||
|
||||||
|
entry.userName?.trim() ||
|
||||||
|
(entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
|
||||||
|
const color = dispatcher
|
||||||
|
? getDispatcherAvatarColor(dispatcher)
|
||||||
|
: getAvatarColorFromName(name);
|
||||||
|
const initials = dispatcher
|
||||||
|
? getInitialsFromName(dispatcher.name)
|
||||||
|
: getInitialsFromName(name);
|
||||||
|
const action = formatBoardAuditAction(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(entry.id) || index}
|
||||||
|
className="flex items-start gap-2.5"
|
||||||
|
style={
|
||||||
|
isSys
|
||||||
|
? {
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "10px",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSys ? (
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded"
|
||||||
|
style={{ background: "#E2E8F0" }}
|
||||||
|
>
|
||||||
|
<Settings size={10} style={{ color: "#64748B" }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[8px] font-bold"
|
||||||
|
style={
|
||||||
|
dispatcher || name !== "Unknown"
|
||||||
|
? { background: `${color}25`, color }
|
||||||
|
: {
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isSys ? (
|
||||||
|
<Text as="p" className="text-[12px] leading-snug" style={{ color: "#374151" }}>
|
||||||
|
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
|
||||||
|
<span style={{ color: "#6B7280" }}> — </span>
|
||||||
|
{action}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text as="p" className="text-[12px] leading-snug">
|
||||||
|
<span className="font-semibold" style={{ color: "#1E293B" }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#D1D5DB" }}> · </span>
|
||||||
|
<span style={{ color: "#374151" }}>{action}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mt-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "#9CA3AF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAuditTime(entry.createdAt, isSys)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
type SlideOverFooterProps = {
|
||||||
|
isCancelled: boolean;
|
||||||
|
editing: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
showUnsaved: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onCancelWO: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onKeepEditing: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverFooter({
|
||||||
|
isCancelled,
|
||||||
|
editing,
|
||||||
|
isDirty,
|
||||||
|
showUnsaved,
|
||||||
|
saving,
|
||||||
|
onCancelWO,
|
||||||
|
onCancelEdit,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
onKeepEditing,
|
||||||
|
}: SlideOverFooterProps) {
|
||||||
|
if (showUnsaved) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-2 px-5 py-3"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-[12px]"
|
||||||
|
style={{ color: "var(--color-warning)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} />
|
||||||
|
You have unsaved changes.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" className="text-[12px]" onClick={onDiscard}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="gap-1.5 text-[12px]"
|
||||||
|
onClick={onSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="text-[12px]" onClick={onKeepEditing}>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
This work order has been cancelled and is read-only.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancelWO}
|
||||||
|
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||||
|
style={{ color: "var(--destructive)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} />
|
||||||
|
Cancel WO
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={editing ? onCancelEdit : onClose}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-[12px] transition-colors"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--color-text-primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--color-text-muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="gap-1.5 text-[12px]"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" className="gap-1.5 text-[12px]" onClick={onEdit}>
|
||||||
|
<Pencil size={13} />
|
||||||
|
Edit WO
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||||
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type SlideOverHeaderProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
isCancelled: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverHeader({
|
||||||
|
row,
|
||||||
|
isCancelled,
|
||||||
|
isFetching = false,
|
||||||
|
closeDisabled = false,
|
||||||
|
onClose,
|
||||||
|
}: SlideOverHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
className="text-[15px] font-semibold leading-none"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
#{row.woNumber || "—"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isCancelled && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[12px] font-medium"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(row.site) && (
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.site}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.isPastDue ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-medium leading-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--status-pastdue-bg)",
|
||||||
|
color: "var(--status-pastdue-text)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
</span>
|
||||||
|
) : row.status ? (
|
||||||
|
<WoTableStatusLabel status={row.status} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Boolean(row.type) && <WoTableTypeLabel type={row.type as WOType} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{isFetching && <CircularProgress size={14} aria-label="Refreshing details" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={closeDisabled}
|
||||||
|
className="shrink-0 rounded p-1.5 transition-colors disabled:opacity-40"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!closeDisabled) {
|
||||||
|
e.currentTarget.style.background = "var(--color-bg-muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type SlideOverInfoRowProps = {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 py-1.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="w-28 shrink-0 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 text-[12.5px]" style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
|
||||||
|
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type SlideOverAuditTabProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
users?: WorkOrderUser[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
|
||||||
|
return <SlideOverAuditList entries={entries} users={users} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
|
||||||
|
import {
|
||||||
|
getAvatarColorFromName,
|
||||||
|
getInitialsFromName,
|
||||||
|
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
|
||||||
|
function formatCommentTime(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const textareaStyle = {
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SlideOverCommentItemProps = {
|
||||||
|
comment: WorkOrderComment;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
draftText: string;
|
||||||
|
isUpdatePending: boolean;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onDraftChange: (value: string) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SlideOverCommentItem({
|
||||||
|
comment,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
draftText,
|
||||||
|
isUpdatePending,
|
||||||
|
onStartEdit,
|
||||||
|
onDraftChange,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
}: SlideOverCommentItemProps) {
|
||||||
|
const author = comment.userName || comment.commenter || "Unknown";
|
||||||
|
const color = getAvatarColorFromName(author);
|
||||||
|
const initials = getInitialsFromName(author);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 py-2.5">
|
||||||
|
<span
|
||||||
|
className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||||
|
style={{ background: `${color}25`, color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="truncate text-[12px] font-semibold"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{formatCommentTime(comment.createdDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && !isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Edit comment"
|
||||||
|
aria-label="Edit comment"
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mt-1.5 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={draftText}
|
||||||
|
onChange={(event) => onDraftChange(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
disabled={isUpdatePending}
|
||||||
|
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={isUpdatePending}
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={!draftText.trim() || isUpdatePending}
|
||||||
|
loading={isUpdatePending}
|
||||||
|
onClick={onSaveEdit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mt-0.5 text-[12px] leading-snug"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{comment.commenttext}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverCommentsTabProps = {
|
||||||
|
comments: WorkOrderComment[];
|
||||||
|
isReadOnly: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
currentUserId?: string | number;
|
||||||
|
isUpdatePending?: boolean;
|
||||||
|
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
|
||||||
|
onUpdateComment?: (
|
||||||
|
commentId: string | number,
|
||||||
|
text: string,
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCommentsTab({
|
||||||
|
comments,
|
||||||
|
isReadOnly,
|
||||||
|
isPending,
|
||||||
|
currentUserId,
|
||||||
|
isUpdatePending = false,
|
||||||
|
onSubmit,
|
||||||
|
onUpdateComment,
|
||||||
|
}: SlideOverCommentsTabProps) {
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
|
||||||
|
const [draftText, setDraftText] = useState("");
|
||||||
|
|
||||||
|
const handlePost = () => {
|
||||||
|
const text = commentText.trim();
|
||||||
|
if (!text || isReadOnly || isPending) return;
|
||||||
|
onSubmit(text, { onSuccess: () => setCommentText("") });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (comment: WorkOrderComment) => {
|
||||||
|
setEditingCommentId(comment.id);
|
||||||
|
setDraftText(comment.commenttext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setDraftText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingCommentId == null || !onUpdateComment) return;
|
||||||
|
const text = draftText.trim();
|
||||||
|
if (!text || isUpdatePending) return;
|
||||||
|
onUpdateComment(editingCommentId, text, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setDraftText("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Add comment…"
|
||||||
|
rows={3}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={!commentText.trim() || isPending}
|
||||||
|
onClick={handlePost}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-bg-muted)",
|
||||||
|
pointerEvents: isReadOnly ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="py-6 text-center text-[12px]"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
No comments yet
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => {
|
||||||
|
const canEdit =
|
||||||
|
!isReadOnly &&
|
||||||
|
Boolean(onUpdateComment) &&
|
||||||
|
isOwnWorkOrderComment(comment, currentUserId);
|
||||||
|
const isEditing = editingCommentId === comment.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(comment.id)}
|
||||||
|
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||||
|
>
|
||||||
|
<SlideOverCommentItem
|
||||||
|
comment={comment}
|
||||||
|
canEdit={canEdit}
|
||||||
|
isEditing={isEditing}
|
||||||
|
draftText={isEditing ? draftText : comment.commenttext}
|
||||||
|
isUpdatePending={isUpdatePending && isEditing}
|
||||||
|
onStartEdit={() => startEdit(comment)}
|
||||||
|
onDraftChange={setDraftText}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={saveEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverCompletionActionsProps = {
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
completionPending: boolean;
|
||||||
|
hasUploadHandler: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onPdfSelected: (files: FileList | null) => void;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
|
||||||
|
if (docStatus === "Yes") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
View / Edit Document
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (docStatus === "NN") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Ban size={13} />
|
||||||
|
Document Not Required — Review
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileText size={13} />
|
||||||
|
Open completion document
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlideOverCompletionActions({
|
||||||
|
docStatus,
|
||||||
|
rowVersion,
|
||||||
|
completionPending,
|
||||||
|
hasUploadHandler,
|
||||||
|
pdfInputRef,
|
||||||
|
onPdfSelected,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hasUploadHandler && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
disabled={!rowVersion}
|
||||||
|
loading={completionPending}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!completionPending ? <Upload size={13} /> : undefined}
|
||||||
|
>
|
||||||
|
Upload signed PDF
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
onPdfSelected(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!rowVersion && (
|
||||||
|
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
Waiting for work order version before upload is available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
onClick={onOpenCompletionDoc}
|
||||||
|
>
|
||||||
|
<DocStatusButtonContent docStatus={docStatus} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
MediaUploader,
|
||||||
|
type MediaUploadRequest,
|
||||||
|
} from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
type SlideOverCompletionMediaProps = {
|
||||||
|
isReadOnly: boolean;
|
||||||
|
media: MediaFile[];
|
||||||
|
mediaPending: boolean;
|
||||||
|
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||||
|
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRemoveMedia?: (id: string) => void;
|
||||||
|
onRetryMedia?: (localId: string) => void;
|
||||||
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionMedia({
|
||||||
|
isReadOnly,
|
||||||
|
media,
|
||||||
|
mediaPending,
|
||||||
|
onUploadMedia,
|
||||||
|
onCategorizeMedia,
|
||||||
|
onRemoveMedia,
|
||||||
|
onRetryMedia,
|
||||||
|
onCancelMedia,
|
||||||
|
}: SlideOverCompletionMediaProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Photos & Videos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||||
|
<MediaUploader
|
||||||
|
uploads={media}
|
||||||
|
onUpload={onUploadMedia}
|
||||||
|
onCategorize={onCategorizeMedia}
|
||||||
|
onRemove={onRemoveMedia}
|
||||||
|
onRetry={onRetryMedia}
|
||||||
|
onCancel={onCancelMedia}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
pending={mediaPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { CheckCircle2, FileText } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverCompletionReadonlyStatusProps = {
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionReadonlyStatus({
|
||||||
|
docStatus,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionReadonlyStatusProps) {
|
||||||
|
if (docStatus === "Yes") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
onClick={onOpenCompletionDoc}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
View Document
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
{docStatus === "NN"
|
||||||
|
? "No completion document was required for this work order."
|
||||||
|
: "No completion document was generated."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
|
||||||
|
type SlideOverCompletionSignoffsProps = {
|
||||||
|
completion?: WorkOrderBoardCompletion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
|
||||||
|
if (!completion || completion.dispatchSignoffs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dispatch sign-offs
|
||||||
|
</span>
|
||||||
|
{completion.dispatchSignoffs.map((signoff, index) => (
|
||||||
|
<div
|
||||||
|
key={`${signoff.dispatchId ?? "d"}-${index}`}
|
||||||
|
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{signoff.signoffType || "Sign-off"}
|
||||||
|
{signoff.name ? ` — ${signoff.name}` : ""}
|
||||||
|
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
|
||||||
|
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
||||||
|
import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
|
||||||
|
import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
|
||||||
|
|
||||||
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverCompletionTabProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
completion?: WorkOrderBoardCompletion;
|
||||||
|
media: MediaFile[];
|
||||||
|
mediaPending?: boolean;
|
||||||
|
completionPending?: boolean;
|
||||||
|
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||||
|
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRemoveMedia?: (id: string) => void;
|
||||||
|
onRetryMedia?: (localId: string) => void;
|
||||||
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
onUploadCompletionPdf?: (file: File) => void;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionTab({
|
||||||
|
row,
|
||||||
|
isReadOnly,
|
||||||
|
completion,
|
||||||
|
media,
|
||||||
|
mediaPending = false,
|
||||||
|
completionPending = false,
|
||||||
|
onUploadMedia,
|
||||||
|
onCategorizeMedia,
|
||||||
|
onRemoveMedia,
|
||||||
|
onRetryMedia,
|
||||||
|
onCancelMedia,
|
||||||
|
onUploadCompletionPdf,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionTabProps) {
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const docStatus = completion?.docStatus || row.docStatus;
|
||||||
|
const templateUrl = completion?.template?.templateUrl;
|
||||||
|
const signOffAttachment = completion?.signOffAttachment;
|
||||||
|
|
||||||
|
if (isMediaWO(row.type)) {
|
||||||
|
return (
|
||||||
|
<SlideOverCompletionMedia
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
media={media}
|
||||||
|
mediaPending={mediaPending}
|
||||||
|
onUploadMedia={onUploadMedia}
|
||||||
|
onCategorizeMedia={onCategorizeMedia}
|
||||||
|
onRemoveMedia={onRemoveMedia}
|
||||||
|
onRetryMedia={onRetryMedia}
|
||||||
|
onCancelMedia={onCancelMedia}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePdfSelected = (files: FileList | null) => {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file || !onUploadCompletionPdf) return;
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
toast.error("Please upload a PDF file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUploadCompletionPdf(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FileText size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Completion Document
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(templateUrl) && (
|
||||||
|
<a
|
||||||
|
href={templateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Download blank template
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(signOffAttachment) && (
|
||||||
|
<a
|
||||||
|
href={signOffAttachment}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
View uploaded document
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReadOnly ? (
|
||||||
|
<SlideOverCompletionReadonlyStatus
|
||||||
|
docStatus={docStatus}
|
||||||
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SlideOverCompletionActions
|
||||||
|
docStatus={docStatus}
|
||||||
|
rowVersion={row.rowVersion}
|
||||||
|
completionPending={completionPending}
|
||||||
|
hasUploadHandler={Boolean(onUploadCompletionPdf)}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onPdfSelected={handlePdfSelected}
|
||||||
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SlideOverCompletionSignoffs completion={completion} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
|
||||||
|
Yes: { bg: "#F0FDF4", text: "#166534" },
|
||||||
|
No: { bg: "#FFF1F2", text: "#BE123C" },
|
||||||
|
NN: { bg: "#F3F4F6", text: "#6B7280" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocBadge({ row }: { row: WorkOrderTableRow }) {
|
||||||
|
if (isMediaWO(row.type)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
|
||||||
|
const style = DOC_BADGE_STYLE[key];
|
||||||
|
const label = key === "NN" ? "NN" : key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
|
||||||
|
style={{
|
||||||
|
background: style.bg,
|
||||||
|
color: style.text,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Paperclip,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import {
|
||||||
|
canDeleteWorkOrderMedia,
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
isWorkOrderMediaTypeAllowed,
|
||||||
|
workOrderMediaAcceptAttribute,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||||
|
image: ImageIcon,
|
||||||
|
video: Film,
|
||||||
|
pdf: FileText,
|
||||||
|
doc: FileIcon,
|
||||||
|
other: Paperclip,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlideOverExtrasTabProps = {
|
||||||
|
uploads: MediaFile[];
|
||||||
|
isReadOnly: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
onUpload: (files: File[]) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverExtrasTab({
|
||||||
|
uploads,
|
||||||
|
isReadOnly,
|
||||||
|
pending = false,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
}: SlideOverExtrasTabProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addFiles = (files: FileList | null) => {
|
||||||
|
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||||
|
const accepted: File[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||||
|
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.push(file);
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) {
|
||||||
|
onUpload(accepted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Extra Documents
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
minHeight: 100,
|
||||||
|
border: "1px dashed var(--color-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||||
|
aria-busy={pending || undefined}
|
||||||
|
>
|
||||||
|
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={workOrderMediaAcceptAttribute()}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => {
|
||||||
|
addFiles(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploads.length === 0 ? (
|
||||||
|
<Text as="p" style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
No extra documents yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{uploads.map((file) => {
|
||||||
|
const ItemIcon = FILETYPE_ICON[file.fileType];
|
||||||
|
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
||||||
|
return (
|
||||||
|
<div key={file.id} className="flex items-center gap-2 py-1">
|
||||||
|
<ItemIcon
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="min-w-0 flex-1 truncate hover:underline"
|
||||||
|
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
{showDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
|
||||||
|
type SlideOverInfoTabAssignedFieldProps = {
|
||||||
|
dispatcher?: WorkOrderUser;
|
||||||
|
dispatcherName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabAssignedField({
|
||||||
|
dispatcher,
|
||||||
|
dispatcherName,
|
||||||
|
}: SlideOverInfoTabAssignedFieldProps) {
|
||||||
|
if (!dispatcher && !dispatcherName) {
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
|
{dispatcher != null && (
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ background: getDispatcherAvatarColor(dispatcher) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dispatcher?.name || dispatcherName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { DocBadge } from "./slide-over-doc-badge";
|
||||||
|
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
|
||||||
|
import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
|
||||||
|
import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
|
||||||
|
|
||||||
|
type SlideOverInfoTabDisplayViewProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
isCancelled: boolean;
|
||||||
|
dispatcher?: WorkOrderUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabDisplayView({
|
||||||
|
source,
|
||||||
|
isCancelled,
|
||||||
|
dispatcher,
|
||||||
|
}: SlideOverInfoTabDisplayViewProps) {
|
||||||
|
const appt = parseApptTime(source.apptTime);
|
||||||
|
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||||
|
<SlideOverInfoRow label="WO #">
|
||||||
|
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Site">
|
||||||
|
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
|
||||||
|
{source.site || "—"}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Type of WO">
|
||||||
|
{source.type ? (
|
||||||
|
<WoTableTypeLabel type={source.type as WOType} />
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Assigned To">
|
||||||
|
<SlideOverInfoTabAssignedField
|
||||||
|
dispatcher={dispatcher}
|
||||||
|
dispatcherName={source.dispatcherName}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Schedule On">
|
||||||
|
<span className="text-[12.5px]">
|
||||||
|
{source.scheduleWeekOnly && source.targetWeek
|
||||||
|
? `Week of ${source.targetWeek}`
|
||||||
|
: source.scheduledOn || "—"}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Due Date">
|
||||||
|
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Appt Time">
|
||||||
|
<span className="text-[12.5px]">{apptDisplay}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Service">
|
||||||
|
{source.pm ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
|
<PmTypeIcon pmType={source.pm} size={14} />
|
||||||
|
{source.pm}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Vendor">
|
||||||
|
<SlideOverInfoTabVendorField source={source} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Status">
|
||||||
|
<SlideOverInfoTabStatusField source={source} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Comp Doc">
|
||||||
|
<DocBadge row={source} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="POC">
|
||||||
|
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{source.pocName || "—"}
|
||||||
|
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||||
|
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
|
||||||
|
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
|
||||||
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
|
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||||
|
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||||
|
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||||
|
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||||
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { DocBadge } from "./slide-over-doc-badge";
|
||||||
|
|
||||||
|
type SlideOverInfoTabEditViewProps = {
|
||||||
|
draft: WorkOrderTableRow;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabEditView({
|
||||||
|
draft,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onDraftChange,
|
||||||
|
onDuplicateFound,
|
||||||
|
}: SlideOverInfoTabEditViewProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SlideOverInfoRow label="WO #">
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={draft.rescheduleCount}
|
||||||
|
carriedOver={draft.carriedOver}
|
||||||
|
currentId={draft.id}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Site">
|
||||||
|
<SiteCell
|
||||||
|
value={draft.site}
|
||||||
|
location={draft.location}
|
||||||
|
locationId={draft.locationId}
|
||||||
|
pocName={draft.pocName}
|
||||||
|
pocPhone={draft.pocPhone}
|
||||||
|
pocNotes={draft.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
onSave={(patch) =>
|
||||||
|
onDraftChange({
|
||||||
|
site: patch.site,
|
||||||
|
location: patch.location,
|
||||||
|
locationId: patch.locationId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Type of WO">
|
||||||
|
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Assigned To">
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={draft.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Schedule On">
|
||||||
|
<DateCell
|
||||||
|
value={draft.scheduledOn}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={draft.targetWeek}
|
||||||
|
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||||
|
onSave={(scheduledOn) =>
|
||||||
|
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||||
|
}
|
||||||
|
onSaveWeek={(weekStart) =>
|
||||||
|
onDraftChange({
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: weekStart,
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
isPastDue: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Due Date">
|
||||||
|
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Appt Time">
|
||||||
|
<ApptTimeCell
|
||||||
|
apptTime={draft.apptTime}
|
||||||
|
onSave={(apptTime) => onDraftChange({ apptTime })}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Service">
|
||||||
|
<PMTypeCell
|
||||||
|
value={draft.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
serviceNotes={draft.serviceNotes}
|
||||||
|
extraServices={draft.extraServices}
|
||||||
|
woNumber={draft.woNumber}
|
||||||
|
onSave={(pm) => onDraftChange({ pm })}
|
||||||
|
onSaveDetails={(details) =>
|
||||||
|
onDraftChange({
|
||||||
|
serviceNotes: details.serviceNotes,
|
||||||
|
extraServices: details.extraServices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Vendor">
|
||||||
|
<VendorCell
|
||||||
|
company={draft.company}
|
||||||
|
tech={draft.tech}
|
||||||
|
techPhone={draft.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={(patch) =>
|
||||||
|
onDraftChange({
|
||||||
|
vendorId: patch.vendorId,
|
||||||
|
company: patch.company,
|
||||||
|
tech: patch.tech,
|
||||||
|
techPhone: patch.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Status">
|
||||||
|
<StatusCell
|
||||||
|
wo={draft}
|
||||||
|
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
||||||
|
onOpenSlideOver={() => {}}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Comp Doc">
|
||||||
|
<DocBadge row={draft} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="POC">
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{draft.pocName || "—"}
|
||||||
|
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverInfoTabStatusFieldProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
|
||||||
|
if (source.isPastDue) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||||
|
style={{ color: "var(--status-pastdue-text)" }}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.status) {
|
||||||
|
return <WoTableStatusLabel status={source.status} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverInfoTabVendorFieldProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display-only vendor summary. Assignment edits belong in edit mode (VendorCell). */
|
||||||
|
export function SlideOverInfoTabVendorField({ source }: SlideOverInfoTabVendorFieldProps) {
|
||||||
|
if (!source.tech && !source.company) {
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
|
||||||
|
{source.tech || source.company}
|
||||||
|
</div>
|
||||||
|
{Boolean(source.tech && source.company) && (
|
||||||
|
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{source.company}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
|
||||||
|
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
|
||||||
|
|
||||||
|
type SlideOverInfoTabProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
draft: WorkOrderTableRow;
|
||||||
|
editing: boolean;
|
||||||
|
isCancelled: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTab({
|
||||||
|
row,
|
||||||
|
draft,
|
||||||
|
editing,
|
||||||
|
isCancelled,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onDraftChange,
|
||||||
|
onDuplicateFound,
|
||||||
|
}: SlideOverInfoTabProps) {
|
||||||
|
const isEditing = editing && !isCancelled;
|
||||||
|
const source = isEditing ? draft : row;
|
||||||
|
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<SlideOverInfoTabEditView
|
||||||
|
draft={draft}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOverInfoTabDisplayView
|
||||||
|
source={source}
|
||||||
|
isCancelled={isCancelled}
|
||||||
|
dispatcher={dispatcher}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
buildSlideOverPatch,
|
||||||
|
isSlideOverDraftDirty,
|
||||||
|
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
|
import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||||
|
|
||||||
|
type UseSlideOverEditStateArgs = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
infoSource: WorkOrderTableRow | null | undefined;
|
||||||
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
closeDisabled: boolean;
|
||||||
|
setTab: (tab: SlideOverTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlideOverEditState({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
closeDisabled,
|
||||||
|
setTab,
|
||||||
|
}: UseSlideOverEditStateArgs) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
||||||
|
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||||
|
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||||
|
|
||||||
|
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
||||||
|
// not on every optimistic parent-row field update during a failed/in-flight save.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!row) {
|
||||||
|
setDraft(null);
|
||||||
|
setBaseline(null);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(row);
|
||||||
|
setBaseline(row);
|
||||||
|
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||||
|
setTab("info");
|
||||||
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||||
|
}, [row?.id, editMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!infoSource) return;
|
||||||
|
if (!editing) {
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
|
||||||
|
return infoSource;
|
||||||
|
});
|
||||||
|
}, [infoSource, editing, baseline]);
|
||||||
|
|
||||||
|
const activeDraft = draft ?? infoSource;
|
||||||
|
const dirtyBaseline = baseline ?? infoSource;
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (!dirtyBaseline || !activeDraft || !editing) return false;
|
||||||
|
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
|
||||||
|
}, [activeDraft, dirtyBaseline, editing]);
|
||||||
|
|
||||||
|
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (!row || !activeDraft || !dirtyBaseline) return;
|
||||||
|
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(row.id, patch, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setBaseline(activeDraft);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
if (!dirtyBaseline) return;
|
||||||
|
setDraft(dirtyBaseline);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestClose = () => {
|
||||||
|
if (closeDisabled) return;
|
||||||
|
if (editing && isDirty) {
|
||||||
|
setUnsavedIntent("close");
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
setUnsavedIntent("cancel-edit");
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dirtyBaseline) setDraft(dirtyBaseline);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
const intent = unsavedIntent;
|
||||||
|
discard();
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
// Close only when Discard was opened from panel close — never from cancel-edit.
|
||||||
|
// Skip when uploads block close (closeDisabled).
|
||||||
|
if (intent === "close" && !closeDisabled) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (!infoSource) return;
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
setEditing(true);
|
||||||
|
setTab("info");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
activeDraft,
|
||||||
|
isDirty,
|
||||||
|
showUnsaved,
|
||||||
|
setShowUnsaved,
|
||||||
|
handleDraftChange,
|
||||||
|
commit,
|
||||||
|
discard,
|
||||||
|
handleDiscard,
|
||||||
|
requestClose,
|
||||||
|
handleCancelEdit,
|
||||||
|
startEditing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
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 { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type {
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUpdateWorkOrderMediaCategory,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
|
||||||
|
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||||
|
|
||||||
|
type UseSlideOverMediaStateArgs = {
|
||||||
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
|
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||||
|
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
|
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||||
|
|
||||||
|
const completionMedia = useMemo(
|
||||||
|
() =>
|
||||||
|
uploads.filter(
|
||||||
|
(item) =>
|
||||||
|
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||||
|
item.uploadStatus === "uploading" ||
|
||||||
|
item.uploadStatus === "failed",
|
||||||
|
),
|
||||||
|
[uploads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const extrasUploads = useMemo(
|
||||||
|
() =>
|
||||||
|
boardMediaItemsToMediaFiles(
|
||||||
|
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
||||||
|
),
|
||||||
|
[detailForRow?.media],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||||
|
const mediaPending =
|
||||||
|
mediaBatchPending ||
|
||||||
|
hasLocalUploading ||
|
||||||
|
uploadMediaMutation.isPending ||
|
||||||
|
deleteMediaMutation.isPending ||
|
||||||
|
categorizeMediaMutation.isPending;
|
||||||
|
|
||||||
|
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
||||||
|
startUploads(requests);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadExtraFiles = async (files: File[]) => {
|
||||||
|
setMediaBatchPending(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMediaBatchPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||||
|
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) => {
|
||||||
|
if (isLocal(id)) {
|
||||||
|
removeLocal(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||||
|
if (!workOrderVersion) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import {
|
||||||
|
useAddWorkOrderComment,
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUpdateWorkOrderComment,
|
||||||
|
useUpdateWorkOrderMediaCategory,
|
||||||
|
useUploadCompletionDoc,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||||
|
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||||
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
|
|
||||||
|
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
||||||
|
|
||||||
|
type UseWorkOrderSlideOverArgs = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkOrderSlideOver({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
saving,
|
||||||
|
}: UseWorkOrderSlideOverArgs) {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const workOrderId = row?.id;
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
|
||||||
|
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
||||||
|
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
|
||||||
|
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
|
||||||
|
const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? "");
|
||||||
|
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
||||||
|
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<SlideOverTab>("info");
|
||||||
|
|
||||||
|
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
|
||||||
|
const detailForRow =
|
||||||
|
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
||||||
|
const infoSource = detailForRow?.info ?? row;
|
||||||
|
const isCancelled = (infoSource?.status as string) === "Canceled";
|
||||||
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
||||||
|
|
||||||
|
const {
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
} = useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
});
|
||||||
|
const completionPending = uploadCompletionMutation.isPending;
|
||||||
|
const closeDisabled = Boolean(saving || mediaPending || completionPending);
|
||||||
|
|
||||||
|
const editState = useSlideOverEditState({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
closeDisabled,
|
||||||
|
setTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadCompletionPdf = (file: File) => {
|
||||||
|
const version = infoSource?.rowVersion;
|
||||||
|
if (!version || !row || !infoSource) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCompletionMutation.mutate(
|
||||||
|
{ file, workOrderVersion: version },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
|
||||||
|
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||||
|
if (!infoSource.completedDate) {
|
||||||
|
patch.completedDate = todayIso();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
onSave(row.id, patch, {
|
||||||
|
onSuccess: () => resolve(),
|
||||||
|
onError: (error) => reject(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetailSkeleton = isLoading && !detailForRow;
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Failed to load work order details.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
commentMutation,
|
||||||
|
updateCommentMutation,
|
||||||
|
tab,
|
||||||
|
setTab,
|
||||||
|
detailForRow,
|
||||||
|
infoSource,
|
||||||
|
isCancelled,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
completionPending,
|
||||||
|
closeDisabled,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
deleteMediaMutation,
|
||||||
|
uploadCompletionPdf,
|
||||||
|
showDetailSkeleton,
|
||||||
|
errorMessage,
|
||||||
|
...editState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||||
|
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||||
|
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AuditTimeline } from "./audit-timeline";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type ActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
type WorkOrderActivitySectionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
onCommentSubmit: (
|
||||||
|
text: string,
|
||||||
|
commentType: "customer" | "internal",
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
isCommentPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderActivitySection({
|
||||||
|
wo,
|
||||||
|
onCommentSubmit,
|
||||||
|
isCommentPending,
|
||||||
|
}: WorkOrderActivitySectionProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
|
||||||
|
const customerComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const internalComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const auditLog = wo.auditLog ?? [];
|
||||||
|
|
||||||
|
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", {
|
||||||
|
onSuccess: () => setCommentText(""),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Activity
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||||
|
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||||
|
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "customer" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(customerComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(internalComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||||
|
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Type a comment..."
|
||||||
|
disabled={isCommentPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-end"
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
loading={isCommentPending}
|
||||||
|
loadingText="Sending…"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Button, Menu, MenuItem } from "@mui/material";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
|
type WorkOrderAssigneeMenuProps = {
|
||||||
|
assignedTo: string;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderAssigneeMenu({
|
||||||
|
assignedTo,
|
||||||
|
users,
|
||||||
|
disabled = false,
|
||||||
|
onAssign,
|
||||||
|
}: WorkOrderAssigneeMenuProps) {
|
||||||
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-busy={disabled || undefined}
|
||||||
|
onClick={(event) => setAnchor(event.currentTarget)}
|
||||||
|
>
|
||||||
|
{disabled ? "Updating…" : assignedTo || "Unassigned"}
|
||||||
|
</Button>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
|
<MenuItem
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign("", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unassigned
|
||||||
|
</MenuItem>
|
||||||
|
{users.map((user) => (
|
||||||
|
<MenuItem
|
||||||
|
key={String(user.id)}
|
||||||
|
selected={assignedTo === user.name}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign(user.id, user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||