feat(work-orders): query broadcast, remove auth bypass, dispatch polish (#100)

* fix(work-orders): address wizard create review findings

[recover] remove malicious eslint payload (was 166c63e4)

* fix(work-orders): remove legacy completedDate EditWorkorder path

[recover] remove malicious eslint payload (was f4e6132b)

* fix(work-orders): use local calendar day for completedDate

Restore todayIso() after parent merge reintroduced UTC slice, and keep the wizard-date-utils regression test.

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

* fix(work-orders): send wizard status and clear POC notes on site change

Map draft.status to lifecycleStatus on board create, and reset pocNotes with POC fields when the wizard site changes.

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

* fix(query): scope broadcast channel per account and clear on auth

Isolate TanStack Query broadcast by userId, dispose and clear on
logout/401, and extract vendor filter drawers for governance.

* feat(work-orders): board wizard create with service notes

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

* fix(work-orders): enforce single-service wizard create per SH-118

Use single service selection in the wizard and omit extraServices from board create.

* fix(vendors): restore filter drawer labels for e2e and visual CI

Use VendorFilterOptions again and keep the Apply filters footer label expected
by vendor Playwright specs and the committed visual baseline.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-14 11:52:48 -03:00 • committed by GitHub
parent ee90d73a44
commit 4af826a1e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2869 additions and 1229 deletions

View 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.

View file

@ -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) |

104
package-lock.json generated
View file

@ -21,8 +21,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/query-broadcast-client-experimental": "5.101.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@ -2332,6 +2333,20 @@
"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": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
@ -3214,6 +3229,30 @@
"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": {
"version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@ -5345,6 +5384,15 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
@ -5393,6 +5441,15 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -5425,6 +5482,40 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6401,6 +6492,15 @@
"devOptional": true,
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View file

@ -55,8 +55,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/query-broadcast-client-experimental": "5.101.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View file

@ -3,14 +3,13 @@ import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
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";
function clearSessionAndRedirectToLogin(): void {
clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null);
stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
@ -48,7 +47,7 @@ export const api: KyInstance = ky.create({
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
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 { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
interface VendorFiltersDrawerProps {
open: boolean;
@ -25,12 +25,18 @@ export function VendorFiltersDrawer({
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
useEffect(() => {
if (open) setDraft(appliedFilters);
if (open) {
setDraft(appliedFilters);
}
}, [open, appliedFilters]);
const activeCount =
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
const handleClear = () => {
setDraft(emptyVendorFilters);
};
const handleApply = () => {
onApply(draft);
onClose();
@ -65,7 +71,7 @@ export function VendorFiltersDrawer({
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
Filters
</Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
{Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
</Stack>
<IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
@ -85,12 +91,7 @@ export function VendorFiltersDrawer({
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Button
variant="outlined"
onClick={() => setDraft(emptyVendorFilters)}
disabled={activeCount === 0}
fullWidth
>
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
Clear all
</Button>
<Button variant="contained" onClick={handleApply} fullWidth>

View file

@ -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) {
const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined;
@ -59,26 +90,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
}
if (isEdit && form.isError) {
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={() => navigate("/vendors")}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
}
if (isEdit && !form.roster) {
return null;
}
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
const subtitle = isEdit
? "Update company details and technicians in one save."
: "Register a company and add zero, one, or multiple technicians.";
const { title, subtitle } = rosterPageCopy(isEdit);
const companySelectionProps = isEdit
? {}
: {
@ -87,6 +106,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSelectCompany: form.selectCompany,
onClearSelectedCompany: form.clearSelectedCompany,
};
const showPortalToken = Boolean(isEdit && vendorId);
return (
<PageShell title={title} subtitle={subtitle}>
@ -107,7 +127,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
{...companySelectionProps}
/>
{isEdit && vendorId !== undefined && vendorId !== "" && (
{showPortalToken && vendorId !== undefined && (
<VendorPortalTokenPanel vendorId={vendorId} />
)}
@ -120,7 +140,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
Cancel
</Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
{submitLabel(form.isSaving, isEdit)}
</Button>
</Stack>
</Stack>

View file

@ -1,5 +1,7 @@
import { useParams } from "react-router";
import { Alert, Box, CircularProgress } from "@mui/material";
import { Box } 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 { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
@ -15,20 +17,17 @@ export default function WorkOrderViewPage() {
const workOrderId = id ?? "";
const view = useWorkOrderViewPage(workOrderId);
if (view.isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
if (view.isLoading && !view.wo) {
return <DetailPageSkeleton variant="view" />;
}
if (view.error || !view.wo) {
if (!view.wo) {
return (
<Box className="p-4">
<Alert severity="error">
{view.error instanceof Error ? view.error.message : "Work order not found."}
</Alert>
<QueryErrorPanel
message={view.error instanceof Error ? view.error.message : "Work order not found."}
onRetry={() => void view.refetch()}
/>
</Box>
);
}

View file

@ -28,6 +28,7 @@ export function CompDocDialogFooterDraftActions({
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
@ -38,6 +39,7 @@ export function CompDocDialogFooterDraftActions({
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
@ -47,7 +49,7 @@ export function CompDocDialogFooterDraftActions({
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
disabled={!rowVersion || isUploading}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
@ -60,6 +62,7 @@ export function CompDocDialogFooterDraftActions({
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

View file

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

View file

@ -1,15 +1,23 @@
import type { MouseEvent } from "react";
import { useRef, useState, type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { 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";
import { useCompDocDialog } from "./use-comp-doc-dialog";
type CompDocDialogProps = {
row: WorkOrderTableRow;
@ -20,6 +28,59 @@ type CompDocDialogProps = {
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,
@ -28,14 +89,33 @@ export function CompDocDialog({
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const dialog = useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
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,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
// A signed PDF on file means the document exists even if docState has not caught up.
const isGenerated = dialog.isGenerated || Boolean(signOffAttachment);
const handleClose = upload.isUploading ? undefined : () => onOpenChange(false);
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
dialog.handleDownloadPdf();
}
return (
<Dialog
@ -66,45 +146,45 @@ export function CompDocDialog({
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">
Completion Document — WO #{dialog.info.woNumber}
</DialogTitle>
<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
info={dialog.info}
row={row}
cfg={dialog.cfg}
isUploading={dialog.isUploading}
isUploading={upload.isUploading}
onClose={() => handleClose?.()}
/>
<CompDocDialogResourceLinks
templateUrl={dialog.templateUrl}
signOffAttachment={dialog.signOffAttachment}
templateUrl={templateUrl}
signOffAttachment={signOffAttachment}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={dialog.info}
row={row}
site={dialog.site}
notes={dialog.notes}
generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated}
isGenerated={isGenerated}
isNotRequired={dialog.isNotRequired}
/>
<CompDocSidebar
row={dialog.info}
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={dialog.isGenerated}
isGenerated={isGenerated}
onNotesChange={dialog.setNotes}
onDateCompletedChange={dialog.setEditDateCompleted}
/>
@ -112,18 +192,18 @@ export function CompDocDialog({
<CompDocDialogFooter
docState={dialog.docState}
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
rowVersion={dialog.rowVersion}
isUploading={dialog.isUploading}
signOffAttachment={dialog.signOffAttachment}
isDocumentsReadOnly={isDocumentsReadOnly}
rowVersion={rowVersion}
isUploading={upload.isUploading}
signOffAttachment={signOffAttachment}
generatedAt={dialog.generatedAt}
pdfInputRef={dialog.pdfInputRef}
pdfInputRef={upload.pdfInputRef}
onMarkNotRequired={dialog.handleMarkNotRequired}
onSaveDraft={dialog.handleSaveDraft}
onGenerate={dialog.handleGenerate}
onUndo={dialog.handleUndo}
onDownloadPdf={dialog.handleDownloadPdf}
onUploadPdf={dialog.handleUploadPdf}
onDownloadPdf={handleDownloadPdf}
onUploadPdf={upload.handleUploadPdf}
/>
</div>
</DialogContent>

View file

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

View file

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

View file

@ -1,12 +1,8 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditRow } from "./comp-doc-edit-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;
@ -14,6 +10,8 @@ type CompDocSidebarProps = {
docState: DocState;
generatedAt: string;
notes: string;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
@ -28,6 +26,8 @@ export function CompDocSidebar({
docState,
generatedAt,
notes,
editAddress,
onAddressChange,
editDateCompleted,
amazonRep,
seaRep,
@ -35,139 +35,25 @@ export function CompDocSidebar({
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
const displayState: DocState =
isGenerated && docState !== "not-required" ? "generated" : docState;
const cfg = DOC_STATE_CFG[displayState];
const StateIcon = cfg.icon;
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 }}>
<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(displayState, generatedAt)}
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
<CompDocEditRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
readOnly={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
<CompDocDetailsSection
row={row}
site={site}
editAddress={editAddress}
onAddressChange={onAddressChange}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
/>
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
</div>
</div>
);

View file

@ -1,7 +1,7 @@
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 { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
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";
@ -14,14 +14,12 @@ type SlideOverInfoTabDisplayViewProps = {
source: WorkOrderTableRow;
isCancelled: boolean;
dispatcher?: WorkOrderUser;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabDisplayView({
source,
isCancelled,
dispatcher,
vendors,
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
@ -73,7 +71,7 @@ export function SlideOverInfoTabDisplayView({
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
<SlideOverInfoTabVendorField source={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} />

View file

@ -1,45 +1,25 @@
import { useState } from "react";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabVendorFieldProps = {
source: WorkOrderTableRow;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
const [vendorDlg, setVendorDlg] = useState(false);
/** 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 (
<>
<button
type="button"
onClick={() => setVendorDlg(true)}
className="text-left transition-opacity hover:opacity-80"
>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
<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>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
)}
</button>
<VendorDialog
open={vendorDlg}
onOpenChange={setVendorDlg}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
onSave={() => setVendorDlg(false)}
/>
</>
)}
</div>
);
}

View file

@ -58,7 +58,6 @@ export function SlideOverInfoTab({
source={source}
isCancelled={isCancelled}
dispatcher={dispatcher}
vendors={vendors}
/>
);
}

View file

@ -1,4 +1,5 @@
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";
@ -56,8 +57,6 @@ export function useSlideOverMediaState({
deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending;
const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
startUploads(requests);
};
@ -74,7 +73,11 @@ export function useSlideOverMediaState({
};
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
if (!workOrderVersion) return;
const workOrderVersion = detailForRow?.info.rowVersion;
if (!workOrderVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
};
@ -83,7 +86,11 @@ export function useSlideOverMediaState({
removeLocal(id);
return;
}
if (!workOrderVersion) return;
const workOrderVersion = detailForRow?.info.rowVersion;
if (!workOrderVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
};

View file

@ -1,377 +0,0 @@
import { useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Radio,
Stack,
TextField,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useTaskListTemplates,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
interface SelectedWorkOrder {
id: string | number;
number: string;
title: string;
}
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
const [description, setDescription] = useState(workOrder.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
title: workOrder.title,
},
]);
const [woSearch, setWoSearch] = useState("");
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
workOrder.locationZip,
);
const { data: taskTemplates = [] } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const siteWorkOrders = useMemo(
() =>
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
[siteWorkOrdersData?.items, workOrder.id],
);
const filteredVendors = useMemo(
() =>
vendors.filter(
(vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
),
[vendors, vendorSearch],
);
const filteredSiteWOs = useMemo(
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
);
const handleSubmit = () => {
if (!selectedVendorId) {
return;
}
dispatchMutation.mutate(
{
workOrderIds: selectedWOs.map((wo) => wo.id),
vendorIds: [selectedVendorId],
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
scheduledDate: scheduledDate || null,
description,
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
);
};
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{selectedWOs.length > 1
? `${selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
label={`${wo.number} — ${wo.title}`}
onDelete={
String(wo.id) !== String(workOrder.id)
? () => setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id))
: undefined
}
/>
))}
</Stack>
{siteWorkOrders.length > 0 && (
<Box className="mt-3">
<TextField
size="small"
value={woSearch}
onChange={(event) => setWoSearch(event.target.value)}
placeholder="Search to filter..."
fullWidth
className="mb-2"
/>
<PaperList>
{filteredSiteWOs.map((item) => (
<Box
key={String(item.id)}
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
onClick={() => {
setSelectedWOs((prev) => [
...prev,
{ id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
<Typography variant="body2">
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}
</PaperList>
</Box>
)}
</Box>
<Box>
<Typography variant="subtitle2" className="mb-2">
Select Vendor
</Typography>
<TextField
size="small"
value={vendorSearch}
onChange={(event) => setVendorSearch(event.target.value)}
placeholder="Search vendors..."
fullWidth
className="mb-2"
/>
{isLoadingVendors ? (
<Typography
sx={{
color: "text.secondary",
}}
>
Loading vendors...
</Typography>
) : filteredVendors.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{filteredVendors.map((vendor) => (
<Box
key={String(vendor.id)}
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
selectedVendorId === vendor.id
? "border-primary bg-primary/10"
: "border-border"
}`}
onClick={() =>
setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id))
}
>
<Radio checked={selectedVendorId === vendor.id} readOnly size="small" />
<Box className="flex-1">
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{Boolean(vendor.tradeSpecialties) && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.tradeSpecialties}
</Typography>
)}
{Boolean(vendor.address) && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.address}
</Typography>
)}
</Box>
{vendor.distanceMiles != null && (
<Typography variant="caption" className="whitespace-nowrap">
{vendor.distanceMiles} miles
</Typography>
)}
</Box>
))}
</Stack>
)}
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="NTE Amount"
type="number"
value={nteAmount}
onChange={(event) => setNteAmount(event.target.value)}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
type="date"
value={scheduledDate}
onChange={(event) => setScheduledDate(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
fullWidth
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
{customItems.length > 0 && (
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
label={item}
onDelete={() => setCustomItems((prev) => prev.filter((_, i) => i !== index))}
/>
))}
</Stack>
)}
<TextField
value={newCustomItem}
onChange={(event) => setNewCustomItem(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newCustomItem.trim()) {
event.preventDefault();
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
setNewCustomItem("");
}
}}
placeholder="Add custom checklist item..."
fullWidth
size="small"
/>
<TextField
label="Description"
multiline
rows={4}
value={description}
onChange={(event) => setDescription(event.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={dispatchMutation.isPending || !selectedVendorId}
>
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
</Button>
</DialogActions>
</Dialog>
);
}
function PaperList({ children }: { children: React.ReactNode }) {
return (
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">{children}</Box>
);
}

View file

@ -0,0 +1,84 @@
import { Chip, MenuItem, Skeleton, Stack, TextField } from "@mui/material";
import type { TaskListTemplate } from "@/domain/work-orders/types/work-order";
interface DispatchCreateChecklistFieldsProps {
taskTemplates: TaskListTemplate[];
isLoadingTemplates: boolean;
selectedTemplateId: string;
onTemplateChange: (id: string) => void;
isPending: boolean;
customItems: string[];
onRemoveCustomItem: (index: number) => void;
newCustomItem: string;
onNewCustomItemChange: (value: string) => void;
onAddCustomItem: () => void;
}
export function DispatchCreateChecklistFields({
taskTemplates,
isLoadingTemplates,
selectedTemplateId,
onTemplateChange,
isPending,
customItems,
onRemoveCustomItem,
newCustomItem,
onNewCustomItemChange,
onAddCustomItem,
}: DispatchCreateChecklistFieldsProps) {
return (
<>
{isLoadingTemplates ? (
<Skeleton variant="rounded" height={56} animation="wave" aria-label="Loading templates" />
) : (
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => onTemplateChange(event.target.value)}
fullWidth
disabled={isPending}
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
)}
{customItems.length > 0 && (
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
label={item}
onDelete={() => onRemoveCustomItem(index)}
/>
))}
</Stack>
)}
<TextField
value={newCustomItem}
onChange={(event) => onNewCustomItemChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newCustomItem.trim()) {
event.preventDefault();
onAddCustomItem();
}
}}
placeholder="Add custom checklist item..."
fullWidth
size="small"
/>
</>
);
}

View file

@ -0,0 +1,38 @@
import { Stack, TextField } from "@mui/material";
interface DispatchCreateDetailsFieldsProps {
nteAmount: string;
onNteAmountChange: (value: string) => void;
scheduledDate: string;
onScheduledDateChange: (value: string) => void;
}
export function DispatchCreateDetailsFields({
nteAmount,
onNteAmountChange,
scheduledDate,
onScheduledDateChange,
}: DispatchCreateDetailsFieldsProps) {
return (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="NTE Amount"
type="number"
value={nteAmount}
onChange={(event) => onNteAmountChange(event.target.value)}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
type="date"
value={scheduledDate}
onChange={(event) => onScheduledDateChange(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
);
}

View file

@ -0,0 +1,110 @@
import { Box, Radio, Skeleton, Stack, TextField, Typography } from "@mui/material";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
interface DispatchCreateVendorSectionProps {
vendors: VendorDropdownItem[];
isLoading: boolean;
vendorSearch: string;
selectedVendorId: string | number | null;
onSearchChange: (value: string) => void;
onSelect: (vendorId: string | number) => void;
}
function VendorLine({ label }: { label: string }) {
return (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
);
}
function VendorOption({
vendor,
isSelected,
onSelect,
}: {
vendor: VendorDropdownItem;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<Box
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
isSelected ? "border-primary bg-primary/10" : "border-border"
}`}
onClick={onSelect}
>
<Radio checked={isSelected} readOnly size="small" />
<Box className="flex-1">
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{Boolean(vendor.contactName) && <VendorLine label={vendor.contactName} />}
{Boolean(vendor.tradeSpecialties) && <VendorLine label={vendor.tradeSpecialties} />}
{Boolean(vendor.address) && <VendorLine label={vendor.address} />}
</Box>
{vendor.distanceMiles != null && (
<Typography variant="caption" className="whitespace-nowrap">
{vendor.distanceMiles} miles
</Typography>
)}
</Box>
);
}
export function DispatchCreateVendorSection({
vendors,
isLoading,
vendorSearch,
selectedVendorId,
onSearchChange,
onSelect,
}: DispatchCreateVendorSectionProps) {
return (
<Box>
<Typography variant="subtitle2" className="mb-2">
Select Vendor
</Typography>
<TextField
size="small"
value={vendorSearch}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search vendors..."
fullWidth
className="mb-2"
/>
{isLoading ? (
<Stack spacing={1} aria-busy="true" role="status" aria-label="Loading vendors">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} variant="rounded" height={56} animation="wave" />
))}
</Stack>
) : vendors.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{vendors.map((vendor) => (
<VendorOption
key={String(vendor.id)}
vendor={vendor}
isSelected={selectedVendorId === vendor.id}
onSelect={() => onSelect(vendor.id)}
/>
))}
</Stack>
)}
</Box>
);
}

View file

@ -0,0 +1,73 @@
import { Box, Chip, Stack, TextField, Typography } from "@mui/material";
import type { SelectedWorkOrderRef } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
interface DispatchCreateWorkOrdersSectionProps {
workOrderId: string | number;
selectedWOs: SelectedWorkOrderRef[];
siteWorkOrders: WorkOrderTableRow[];
filteredSiteWOs: WorkOrderTableRow[];
woSearch: string;
onWoSearchChange: (value: string) => void;
onAddWO: (item: WorkOrderTableRow) => void;
onRemoveWO: (id: string | number) => void;
}
export function DispatchCreateWorkOrdersSection({
workOrderId,
selectedWOs,
siteWorkOrders,
filteredSiteWOs,
woSearch,
onWoSearchChange,
onAddWO,
onRemoveWO,
}: DispatchCreateWorkOrdersSectionProps) {
return (
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
label={`${wo.number} — ${wo.title}`}
onDelete={String(wo.id) !== String(workOrderId) ? () => onRemoveWO(wo.id) : undefined}
/>
))}
</Stack>
{siteWorkOrders.length > 0 && (
<Box className="mt-3">
<TextField
size="small"
value={woSearch}
onChange={(event) => onWoSearchChange(event.target.value)}
placeholder="Search to filter..."
fullWidth
className="mb-2"
/>
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">
{filteredSiteWOs.map((item) => (
<Box
key={String(item.id)}
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
onClick={() => onAddWO(item)}
>
<Typography variant="body2">
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}
</Box>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,114 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { Button } from "@/components/ui/button";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { useDispatchCreateController } from "@/app/(protected)/workorders/_hooks/use-dispatch-create-controller";
import { DispatchCreateWorkOrdersSection } from "./dispatch-create-work-orders-section";
import { DispatchCreateVendorSection } from "./dispatch-create-vendor-section";
import { DispatchCreateChecklistFields } from "./dispatch-create-checklist-fields";
import { DispatchCreateDetailsFields } from "./dispatch-create-details-fields";
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
const controller = useDispatchCreateController(workOrder, onClose, onDispatched);
return (
<Dialog open onClose={controller.handleClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{controller.selectedWOs.length > 1
? `${controller.selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
disabled={controller.isPending}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<DispatchCreateWorkOrdersSection
workOrderId={workOrder.id}
selectedWOs={controller.selectedWOs}
siteWorkOrders={controller.siteWorkOrders}
filteredSiteWOs={controller.filteredSiteWOs}
woSearch={controller.woSearch}
onWoSearchChange={controller.setWoSearch}
onAddWO={controller.addSelectedWO}
onRemoveWO={controller.removeSelectedWO}
/>
<DispatchCreateVendorSection
vendors={controller.vendors}
isLoading={controller.isLoadingVendors}
vendorSearch={controller.vendorSearch}
selectedVendorId={controller.selectedVendorId}
onSearchChange={controller.setVendorSearch}
onSelect={(vendorId) =>
controller.setSelectedVendorId((current) => (current === vendorId ? null : vendorId))
}
/>
<DispatchCreateDetailsFields
nteAmount={controller.nteAmount}
onNteAmountChange={controller.setNteAmount}
scheduledDate={controller.scheduledDate}
onScheduledDateChange={controller.setScheduledDate}
/>
<DispatchCreateChecklistFields
taskTemplates={controller.taskTemplates}
isLoadingTemplates={controller.isLoadingTemplates}
selectedTemplateId={controller.selectedTemplateId}
onTemplateChange={controller.setSelectedTemplateId}
isPending={controller.isPending}
customItems={controller.customItems}
onRemoveCustomItem={controller.removeCustomItem}
newCustomItem={controller.newCustomItem}
onNewCustomItemChange={controller.setNewCustomItem}
onAddCustomItem={controller.addCustomItem}
/>
<TextField
label="Description"
multiline
rows={4}
value={controller.description}
onChange={(event) => controller.setDescription(event.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={onClose} disabled={controller.isPending}>
Cancel
</Button>
<Button
onClick={controller.handleSubmit}
disabled={!controller.selectedVendorId}
loading={controller.isPending}
loadingText="Dispatching…"
>
Dispatch
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = {
items: DispatchChecklistItem[];
isLocked: boolean;
isUpdating: boolean;
isAdding?: boolean;
newItem: string;
onNewItemChange: (value: string) => void;
onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
@ -16,6 +17,7 @@ export function DispatchChecklistSection({
items,
isLocked,
isUpdating,
isAdding = false,
newItem,
onNewItemChange,
onToggleItem,
@ -58,14 +60,16 @@ export function DispatchChecklistSection({
value={newItem}
onChange={(event) => onNewItemChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newItem.trim()) {
if (event.key === "Enter" && newItem.trim() && !isAdding) {
event.preventDefault();
onAddItem(newItem.trim());
}
}}
placeholder="Add checklist item..."
placeholder={isAdding ? "Adding…" : "Add checklist item..."}
disabled={isAdding}
fullWidth
className="mt-2"
slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }}
/>
)}
</Box>

View file

@ -0,0 +1,103 @@
import { DialogContent, Skeleton } from "@mui/material";
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
import type { DispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
import { DispatchChecklistSection } from "./dispatch-checklist-section";
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
interface DispatchDetailBodyProps {
dispatch: DispatchDetail;
controller: DispatchDetailController;
}
export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyProps) {
return (
<DialogContent className="flex flex-col gap-6">
<DispatchDetailsGrid>
{controller.editing ? (
<DispatchEditForm
form={controller.form}
onChange={controller.setForm}
onSave={controller.handleSave}
onCancel={() => controller.setEditing(false)}
isSaving={controller.updateDispatchMutation.isPending}
/>
) : (
<DispatchDetailsView
dispatch={dispatch}
isLocked={controller.isLocked}
onEdit={controller.beginEditing}
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
/>
)}
</DispatchDetailsGrid>
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
<DispatchChecklistSection
items={dispatch.checklistItems}
isLocked={controller.isLocked}
isUpdating={controller.updateChecklistMutation.isPending}
isAdding={controller.addChecklistMutation.isPending}
newItem={controller.newChecklistItem}
onNewItemChange={controller.setNewChecklistItem}
onToggleItem={(itemId, isCompleted) =>
controller.updateChecklistMutation.mutate({
itemId,
isCompleted,
completedBy: controller.userName,
})
}
onAddItem={(text) =>
controller.addChecklistMutation.mutate(text, {
onSuccess: () => controller.setNewChecklistItem(""),
})
}
/>
)}
<DispatchSignoffsSection
signoffs={dispatch.signoffs}
isLocked={controller.isLocked}
isSigningOff={controller.addSignoffMutation.isPending}
onCollectSignature={controller.setSigCaptureType}
/>
{controller.isLoadingUplifts ? (
<Skeleton variant="rounded" height={80} animation="wave" aria-label="Loading uplifts" />
) : (
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
onRequestChanges={(uplift) =>
controller.setUpliftAction({ type: "request-changes", uplift })
}
/>
)}
<DispatchVendorCommunication
comments={dispatch.comments}
commentText={controller.commentText}
sendEmail={controller.sendEmail}
isLocked={controller.isLocked}
isPending={controller.addCommentMutation.isPending}
onCommentTextChange={controller.setCommentText}
onSendEmailChange={controller.setSendEmail}
onSubmit={controller.handleComment}
/>
<DispatchVerifyFooter
verifiedAt={dispatch.verifiedAt}
verifiedBy={dispatch.verifiedBy}
canVerify={controller.verifyState.canVerify}
missing={controller.verifyState.missing}
isPending={controller.verifyMutation.isPending}
onVerify={() => controller.verifyMutation.mutate(undefined)}
/>
</DialogContent>
);
}

View file

@ -1,5 +1,6 @@
import { Box, Button, MenuItem, Stack, TextField } from "@mui/material";
import { Box, MenuItem, Stack, TextField } from "@mui/material";
import { DetailField } from "@/components/common/detail-field";
import { Button } from "@/components/ui/button";
import {
DISPATCH_STATUS_OPTIONS,
type DispatchDetail,
@ -84,10 +85,12 @@ export function DispatchEditForm({
onChange={(event) => onChange({ ...form, description: event.target.value })}
/>
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
<Button onClick={onSave} loading={isSaving} loadingText="Saving…">
Save
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onCancel}>Cancel</Button>
</Stack>
</>
);
@ -138,12 +141,10 @@ export function DispatchDetailsView({
)}
{!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
<Button variant="destructive" onClick={onCancelDispatch}>
Cancel Dispatch
</Button>
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
<Button onClick={onEdit}>Edit</Button>
</Stack>
)}
</>

View file

@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils";
type DispatchSignoffsSectionProps = {
signoffs: DispatchSignoff[];
isLocked: boolean;
isSigningOff?: boolean;
onCollectSignature: (type: "Customer" | "Vendor") => void;
};
export function DispatchSignoffsSection({
signoffs,
isLocked,
isSigningOff = false,
onCollectSignature,
}: DispatchSignoffsSectionProps) {
return (
@ -49,9 +51,9 @@ export function DispatchSignoffsSection({
variant="outlined"
className="mt-2"
onClick={() => onCollectSignature(type)}
disabled={isLocked}
disabled={isLocked || isSigningOff}
>
{isLocked ? "Not signed" : "Collect Signature"}
{isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"}
</Button>
)}
</Box>

View file

@ -1,6 +1,7 @@
import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
import { Box, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import type { DispatchComment } from "@/domain/work-orders/types/work-order";
type DispatchVendorCommunicationProps = {
@ -61,10 +62,11 @@ export function DispatchVendorCommunication({
/>
<Button
type="submit"
variant="contained"
disabled={isLocked || isPending || !commentText.trim()}
disabled={isLocked || !commentText.trim()}
loading={isPending}
loadingText="Sending…"
>
{isPending ? "Sending..." : "Send"}
Send
</Button>
</Stack>
</Box>

View file

@ -1,4 +1,5 @@
import { Alert, Button, Stack, Typography } from "@mui/material";
import { Alert, Stack, Typography } from "@mui/material";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/time-utils";
type DispatchVerifyFooterProps = {
@ -34,10 +35,15 @@ export function DispatchVerifyFooter({
</Typography>
)}
<Button
variant="contained"
color="success"
disabled={!canVerify || isPending}
disabled={!canVerify}
loading={isPending}
loadingText="Verifying…"
onClick={onVerify}
sx={{
backgroundColor: "success.main",
color: "#fff",
"&:hover": { backgroundColor: "success.dark" },
}}
>
Verify & Close
</Button>

View file

@ -1,13 +1,10 @@
import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Dialog, DialogTitle } from "@mui/material";
import { SignatureCapture } from "@/components/common/signature-capture";
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
import { DispatchChecklistSection } from "./dispatch-checklist-section";
import { DispatchDetailSkeleton } from "@/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton";
import { guardDialogClose } from "@/lib/dialog-guard";
import { DispatchDetailBody } from "./dispatch-detail-body";
import { DispatchDetailHeader } from "./dispatch-detail-header";
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
interface DispatchDetailModalProps {
@ -23,13 +20,16 @@ export function DispatchDetailModal({
onClose,
}: DispatchDetailModalProps) {
const controller = useDispatchDetailController(dispatchId, workOrderId);
const handleClose = guardDialogClose(controller.isMutating, onClose);
const isDecidingUplift =
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending;
if (controller.isLoading) {
if (controller.isLoading && !controller.dispatch) {
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12">
<CircularProgress />
</DialogContent>
<DispatchDetailSkeleton />
</Dialog>
);
}
@ -42,94 +42,23 @@ export function DispatchDetailModal({
return (
<>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
<Dialog open onClose={handleClose} maxWidth="md" fullWidth scroll="paper">
<DialogTitle>
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
<DispatchDetailHeader
dispatch={dispatch}
onClose={() => {
if (!controller.isMutating) onClose();
}}
/>
</DialogTitle>
<DialogContent className="flex flex-col gap-6">
<DispatchDetailsGrid>
{controller.editing ? (
<DispatchEditForm
form={controller.form}
onChange={controller.setForm}
onSave={controller.handleSave}
onCancel={() => controller.setEditing(false)}
isSaving={controller.updateDispatchMutation.isPending}
/>
) : (
<DispatchDetailsView
dispatch={dispatch}
isLocked={controller.isLocked}
onEdit={controller.beginEditing}
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
/>
)}
</DispatchDetailsGrid>
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
<DispatchChecklistSection
items={dispatch.checklistItems}
isLocked={controller.isLocked}
isUpdating={controller.updateChecklistMutation.isPending}
newItem={controller.newChecklistItem}
onNewItemChange={controller.setNewChecklistItem}
onToggleItem={(itemId, isCompleted) =>
controller.updateChecklistMutation.mutate({
itemId,
isCompleted,
completedBy: controller.userName,
})
}
onAddItem={(text) =>
controller.addChecklistMutation.mutate(text, {
onSuccess: () => controller.setNewChecklistItem(""),
})
}
/>
)}
<DispatchSignoffsSection
signoffs={dispatch.signoffs}
isLocked={controller.isLocked}
onCollectSignature={controller.setSigCaptureType}
/>
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
onRequestChanges={(uplift) =>
controller.setUpliftAction({ type: "request-changes", uplift })
}
/>
<DispatchVendorCommunication
comments={dispatch.comments}
commentText={controller.commentText}
sendEmail={controller.sendEmail}
isLocked={controller.isLocked}
isPending={controller.addCommentMutation.isPending}
onCommentTextChange={controller.setCommentText}
onSendEmailChange={controller.setSendEmail}
onSubmit={controller.handleComment}
/>
<DispatchVerifyFooter
verifiedAt={dispatch.verifiedAt}
verifiedBy={dispatch.verifiedBy}
canVerify={controller.verifyState.canVerify}
missing={controller.verifyState.missing}
isPending={controller.verifyMutation.isPending}
onVerify={() => controller.verifyMutation.mutate(undefined)}
/>
</DialogContent>
<DispatchDetailBody dispatch={dispatch} controller={controller} />
</Dialog>
{controller.sigCaptureType != null && (
<SignatureCapture
title={`${controller.sigCaptureType} Sign-off`}
isPending={controller.addSignoffMutation.isPending}
onClose={() => controller.setSigCaptureType(null)}
onSave={(signature, method, name) => {
controller.addSignoffMutation.mutate(
@ -151,13 +80,13 @@ export function DispatchDetailModal({
type={controller.upliftAction.type}
requestedNTE={controller.upliftAction.uplift.requestedNTE}
currentNTE={controller.upliftAction.uplift.currentNTE}
onClose={() => controller.setUpliftAction(null)}
onClose={() => {
if (!isDecidingUplift) {
controller.setUpliftAction(null);
}
}}
onConfirm={controller.handleUpliftConfirm}
isPending={
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending
}
isPending={isDecidingUplift}
/>
)}
</>

View file

@ -1,6 +1,5 @@
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
@ -8,6 +7,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { guardDialogClose } from "@/lib/dialog-guard";
type UpliftDecisionDialogProps = {
open: boolean;
@ -39,6 +40,7 @@ export function UpliftDecisionDialog({
const noteRequired = type === "reject" || type === "request-changes";
const handleClose = () => {
if (isPending) return;
setNote("");
onClose();
};
@ -46,11 +48,10 @@ export function UpliftDecisionDialog({
const handleConfirm = () => {
if (noteRequired && !note.trim()) return;
onConfirm(note.trim());
setNote("");
};
return (
<Dialog open={open} onClose={handleClose}>
<Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
<DialogContent>
{type === "approve" && (
@ -68,14 +69,18 @@ export function UpliftDecisionDialog({
required={noteRequired}
value={note}
onChange={(event) => setNote(event.target.value)}
disabled={isPending}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="outline" onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
color={type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || isPending : isPending}
variant={type === "reject" ? "destructive" : "default"}
disabled={noteRequired && !note.trim()}
loading={isPending}
loadingText="Confirming…"
onClick={handleConfirm}
>
Confirm

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -20,7 +20,7 @@ export type MediaUploadRequest = {
type MediaUploaderProps = {
uploads: MediaFile[];
/** Upload-then-categorize: files only ÔÇö category assigned after persist. */
/** Upload-then-categorize: files only — category assigned after persist. */
onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,159 @@
import { useMemo, useState } from "react";
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useTaskListTemplates,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { guardDialogClose } from "@/lib/dialog-guard";
export function useDispatchCreateController(
workOrder: WorkOrderDetail,
onClose: () => void,
onDispatched: () => void,
) {
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
const [description, setDescription] = useState(workOrder.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
title: workOrder.title,
},
]);
const [woSearch, setWoSearch] = useState("");
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
workOrder.locationZip,
);
const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const isPending = dispatchMutation.isPending;
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const siteWorkOrders = useMemo(
() =>
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
[siteWorkOrdersData?.items, workOrder.id],
);
const filteredVendors = useMemo(
() =>
vendors.filter(
(vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
),
[vendors, vendorSearch],
);
const filteredSiteWOs = useMemo(
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
);
const addSelectedWO = (item: { id: string | number; woNumber: string; title: string }) => {
setSelectedWOs((prev) => [...prev, { id: item.id, number: item.woNumber, title: item.title }]);
setWoSearch("");
};
const removeSelectedWO = (id: string | number) => {
setSelectedWOs((prev) => prev.filter((item) => item.id !== id));
};
const addCustomItem = () => {
if (!newCustomItem.trim()) return;
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
setNewCustomItem("");
};
const removeCustomItem = (index: number) => {
setCustomItems((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = () => {
if (!selectedVendorId) {
return;
}
dispatchMutation.mutate(
{
workOrderIds: selectedWOs.map((wo) => wo.id),
vendorIds: [selectedVendorId],
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
scheduledDate: scheduledDate || null,
description,
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
);
};
return {
selectedVendorId,
setSelectedVendorId,
nteAmount,
setNteAmount,
scheduledDate,
setScheduledDate,
description,
setDescription,
vendorSearch,
setVendorSearch,
selectedTemplateId,
setSelectedTemplateId,
customItems,
newCustomItem,
setNewCustomItem,
addCustomItem,
removeCustomItem,
selectedWOs,
addSelectedWO,
removeSelectedWO,
woSearch,
setWoSearch,
vendors: filteredVendors,
isLoadingVendors,
taskTemplates,
isLoadingTemplates,
siteWorkOrders,
filteredSiteWOs,
isPending,
handleClose: guardDialogClose(isPending, onClose),
handleSubmit,
};
}
export type DispatchCreateController = ReturnType<typeof useDispatchCreateController>;

View file

@ -1,12 +1,6 @@
import { useMemo, useState } from "react";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
import { useDispatchUpliftController } from "@/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller";
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddChecklistItem,
@ -53,8 +47,8 @@ export function useDispatchDetailController(
workOrderId: string | number,
) {
const { user } = useAuthContext();
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
const uplift = useDispatchUpliftController(dispatchId);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DispatchEditFormState>({
@ -68,10 +62,6 @@ export function useDispatchDetailController(
const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "reject" | "request-changes";
uplift: UpliftRequest;
} | null>(null);
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
const addCommentMutation = useAddDispatchComment(dispatchId);
@ -79,9 +69,6 @@ export function useDispatchDetailController(
const addChecklistMutation = useAddChecklistItem(dispatchId);
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId);
const rejectUpliftMutation = useRejectUplift(dispatchId);
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
const userName =
(typeof user?.name === "string" ? user.name : undefined) ??
@ -140,30 +127,22 @@ export function useDispatchDetailController(
);
};
const handleUpliftConfirm = (note: string) => {
if (!upliftAction) {
return;
}
const id = upliftAction.uplift.id;
if (upliftAction.type === "approve") {
approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
return;
}
if (!note.trim()) {
return;
}
const mutation =
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
};
const isMutating =
updateDispatchMutation.isPending ||
addCommentMutation.isPending ||
updateChecklistMutation.isPending ||
addChecklistMutation.isPending ||
addSignoffMutation.isPending ||
verifyMutation.isPending ||
uplift.isMutating;
return {
dispatch,
isLoading,
uplifts,
isFetching,
refetch,
uplifts: uplift.uplifts,
isLoadingUplifts: uplift.isLoadingUplifts,
editing,
form,
setForm,
@ -175,25 +154,28 @@ export function useDispatchDetailController(
setSendEmail,
sigCaptureType,
setSigCaptureType,
upliftAction,
setUpliftAction,
upliftAction: uplift.upliftAction,
setUpliftAction: uplift.setUpliftAction,
isLocked,
isMutating,
userName,
verifyState,
beginEditing,
handleSave,
handleCancelDispatch,
handleComment,
handleUpliftConfirm,
handleUpliftConfirm: uplift.handleUpliftConfirm,
setEditing,
updateDispatchMutation,
updateChecklistMutation,
addChecklistMutation,
addSignoffMutation,
verifyMutation,
approveUpliftMutation,
rejectUpliftMutation,
requestChangesUpliftMutation,
approveUpliftMutation: uplift.approveUpliftMutation,
rejectUpliftMutation: uplift.rejectUpliftMutation,
requestChangesUpliftMutation: uplift.requestChangesUpliftMutation,
addCommentMutation,
};
}
export type DispatchDetailController = ReturnType<typeof useDispatchDetailController>;

View file

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

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -19,6 +19,12 @@ type SessionUploadRequest = {
signal: AbortSignal;
};
function revokeBlobUrl(url: string | undefined) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file);
return {
@ -39,10 +45,33 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
function revokeBlobUrl(url: string) {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] {
const next = [...previous];
for (const entry of entries) {
const index = next.findIndex((item) => item.id === entry.id);
if (index >= 0) {
revokeBlobUrl(next[index]?.url);
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
}
function dropUploads(
previous: LocalUpload[],
matches: (item: LocalUpload) => boolean,
): LocalUpload[] {
const kept: LocalUpload[] = [];
for (const item of previous) {
if (matches(item)) {
revokeBlobUrl(item.url);
continue;
}
kept.push(item);
}
return kept;
}
export function useMediaUploadSession(
@ -55,7 +84,7 @@ export function useMediaUploadSession(
const previousWorkOrderIdRef = useRef(workOrderId);
// Drop local tiles and abort in-flight uploads when the bound WO changes.
// Skip the first bind (undefined  id) so an early upload is not cleared.
// Skip the first bind (undefined → id) so an early upload is not cleared.
useEffect(() => {
if (previousWorkOrderIdRef.current === workOrderId) return;
const hadPrevious = previousWorkOrderIdRef.current != null;
@ -66,12 +95,7 @@ export function useMediaUploadSession(
controller.abort();
}
controllersRef.current.clear();
setLocalUploads((prev) => {
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
setLocalUploads((prev) => dropUploads(prev, () => true));
}, [workOrderId]);
const serverUploads = useMemo(
@ -98,6 +122,27 @@ export function useMediaUploadSession(
return controller;
}
function settleAttempt(request: SessionUploadRequest) {
clearController(request.localId);
setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request)));
}
function failAttempt(request: SessionUploadRequest, error: Error) {
// Keep the controller entry while the attempt is retryable; retry replaces it.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
}
function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId,
@ -105,23 +150,8 @@ export function useMediaUploadSession(
signal: replaceController(request.localId).signal,
}));
setLocalUploads((prev) => {
const next = [...prev];
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
});
const entries = sessionRequests.map(toLocalUpload);
setLocalUploads((prev) => upsertUploads(prev, entries));
for (const request of sessionRequests) {
void uploadMutateAsync({
@ -135,29 +165,13 @@ export function useMediaUploadSession(
);
},
})
.then(() => {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.then(() => settleAttempt(request))
.catch((error: Error) => {
if (error.name === "AbortError") {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
settleAttempt(request);
return;
}
// Keep controller entry only while retryable; replaceController on retry.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
failAttempt(request, error);
});
}
}
@ -174,21 +188,19 @@ export function useMediaUploadSession(
]);
}
function cancelUpload(localId: string) {
/** Abort any in-flight attempt for the tile and drop it from the session. */
function dropLocal(localId: string) {
controllersRef.current.get(localId)?.abort();
clearController(localId);
setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId);
if (target) {
revokeBlobUrl(target.url);
}
return prev.filter((item) => item.id !== localId);
});
setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
}
function cancelUpload(localId: string) {
dropLocal(localId);
}
function removeLocal(id: string) {
clearController(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
dropLocal(id);
}
function isLocal(id: string) {

View file

@ -0,0 +1,114 @@
import { useState } from "react";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
export interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
phone: string;
email: string;
notes: string;
}
export function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
export function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}
/**
* Owns the "edit assigned vendor" dialog state for a work order: loading the
* current vendor contact details, tracking in-flight edits, and saving them
* back via `useUpdateWorkOrderVendor`.
*/
export function useVendorEditor(workOrderId: string, onSaved: () => void) {
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const openVendorEditor = async (vendorId: string | number, vendorName: string) => {
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const closeVendorEditor = () => {
if (!vendorMutation.isPending) setVendorEditor(null);
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
onSaved();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
return {
vendorEditor,
setVendorEditor,
vendorEditorLoading,
vendorEditorError,
vendorMutation,
openVendorEditor,
closeVendorEditor,
saveVendorEditor,
};
}

View file

@ -0,0 +1,82 @@
import { useMemo, useState } from "react";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
export function useWorkOrderViewController(workOrderId: string) {
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
if (activeTab !== "customer" && activeTab !== "internal") {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
return {
wo,
isLoading,
error,
refetch,
users,
statusMutation,
assignmentMutation,
commentMutation,
vendorEditor,
activeTab,
setActiveTab,
commentText,
setCommentText,
dispatchOpen,
setDispatchOpen,
dispatchDetailId,
setDispatchDetailId,
statusAnchor,
setStatusAnchor,
assignAnchor,
setAssignAnchor,
customerComments,
internalComments,
auditLog,
handleCommentSubmit,
};
}
export type WorkOrderViewController = ReturnType<typeof useWorkOrderViewController>;

View file

@ -0,0 +1,91 @@
import { Box, Button, Tab, Tabs, TextField, Typography } from "@mui/material";
import type { RefObject } from "react";
interface SignatureCaptureFieldsProps {
isPending: boolean;
mode: "draw" | "type";
onModeChange: (mode: "draw" | "type") => void;
signerName: string;
onSignerNameChange: (value: string) => void;
typedName: string;
onTypedNameChange: (value: string) => void;
canvasRef: RefObject<HTMLCanvasElement | null>;
startDraw: (event: React.MouseEvent | React.TouchEvent) => void;
draw: (event: React.MouseEvent | React.TouchEvent) => void;
stopDraw: () => void;
clearCanvas: () => void;
}
export function SignatureCaptureFields({
isPending,
mode,
onModeChange,
signerName,
onSignerNameChange,
typedName,
onTypedNameChange,
canvasRef,
startDraw,
draw,
stopDraw,
clearCanvas,
}: SignatureCaptureFieldsProps) {
return (
<>
<TextField
label="Full Name"
value={signerName}
onChange={(event) => onSignerNameChange(event.target.value)}
placeholder="Enter signer's full name"
fullWidth
size="small"
disabled={isPending}
/>
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => onModeChange(value)}>
<Tab label="Draw" value="draw" disabled={isPending} />
<Tab label="Type" value="type" disabled={isPending} />
</Tabs>
{mode === "draw" ? (
<Box className="relative">
<canvas
ref={canvasRef}
width={460}
height={180}
className="block w-full cursor-crosshair rounded border border-border bg-background"
onMouseDown={isPending ? undefined : startDraw}
onMouseMove={isPending ? undefined : draw}
onMouseUp={isPending ? undefined : stopDraw}
onMouseLeave={isPending ? undefined : stopDraw}
onTouchStart={isPending ? undefined : startDraw}
onTouchMove={isPending ? undefined : draw}
onTouchEnd={isPending ? undefined : stopDraw}
/>
<Button size="small" onClick={clearCanvas} className="mt-2" disabled={isPending}>
Clear
</Button>
</Box>
) : (
<Box className="flex flex-col gap-2">
<TextField
value={typedName}
onChange={(event) => onTypedNameChange(event.target.value)}
placeholder="Type your name"
fullWidth
size="small"
disabled={isPending}
/>
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
>
{typedName}
</Typography>
)}
</Box>
)}
</>
);
}

View file

@ -1,119 +1,38 @@
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
import { useEffect, useRef, useState } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useState } from "react";
import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { SignatureCaptureFields } from "@/components/common/signature-capture-fields";
import { useSignatureCanvas } from "@/components/common/use-signature-canvas";
interface SignatureCaptureProps {
title?: string;
onClose: () => void;
onSave: (signature: string, method: "drawn" | "typed", name: string) => void;
isPending?: boolean;
}
export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) {
export function SignatureCapture({
onClose,
onSave,
title,
isPending = false,
}: SignatureCaptureProps) {
const [mode, setMode] = useState<"draw" | "type">("draw");
const [typedName, setTypedName] = useState("");
const [signerName, setSignerName] = useState("");
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
useEffect(() => {
if (mode === "draw" && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
ctx.lineWidth = 2;
ctx.lineCap = "round";
}
}, [mode]);
const getPos = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
const touch = "touches" in event ? event.touches[0] : event;
if (!touch) {
return { x: 0, y: 0 };
}
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
};
const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) {
return;
}
isDrawing.current = true;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
if (!isDrawing.current || !canvasRef.current) {
return;
}
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
const stopDraw = () => {
isDrawing.current = false;
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const { canvasRef, startDraw, draw, stopDraw, clearCanvas } = useSignatureCanvas(mode);
const handleSave = () => {
if (!signerName.trim()) {
if (isPending || !signerName.trim()) {
return;
}
@ -134,77 +53,41 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
};
const dialog = (
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open onClose={guardDialogClose(isPending, onClose)} maxWidth="sm" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
{title || "Capture Signature"}
<IconButton aria-label="Close" onClick={onClose} size="small">
<IconButton aria-label="Close" onClick={onClose} size="small" disabled={isPending}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<TextField
label="Full Name"
value={signerName}
onChange={(event) => setSignerName(event.target.value)}
placeholder="Enter signer's full name"
fullWidth
size="small"
<SignatureCaptureFields
isPending={isPending}
mode={mode}
onModeChange={setMode}
signerName={signerName}
onSignerNameChange={setSignerName}
typedName={typedName}
onTypedNameChange={setTypedName}
canvasRef={canvasRef}
startDraw={startDraw}
draw={draw}
stopDraw={stopDraw}
clearCanvas={clearCanvas}
/>
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => setMode(value)}>
<Tab label="Draw" value="draw" />
<Tab label="Type" value="type" />
</Tabs>
{mode === "draw" ? (
<Box className="relative">
<canvas
ref={canvasRef}
width={460}
height={180}
className="block w-full cursor-crosshair rounded border border-border bg-background"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={stopDraw}
/>
<Button size="small" onClick={clearCanvas} className="mt-2">
Clear
</Button>
</Box>
) : (
<Box className="flex flex-col gap-2">
<TextField
value={typedName}
onChange={(event) => setTypedName(event.target.value)}
placeholder="Type your name"
fullWidth
size="small"
/>
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
>
{typedName}
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!signerName.trim() || (mode === "type" && !typedName.trim())}
disabled={isPending || !signerName.trim() || (mode === "type" && !typedName.trim())}
>
Save Signature
{isPending ? "Saving…" : "Save Signature"}
</Button>
</DialogActions>
</Dialog>

View file

@ -0,0 +1,85 @@
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
import { useEffect, useRef } from "react";
export function useSignatureCanvas(mode: "draw" | "type") {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
useEffect(() => {
if (mode !== "draw" || !canvasRef.current) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
ctx.lineWidth = 2;
ctx.lineCap = "round";
}, [mode]);
const getPos = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
const touch = "touches" in event ? event.touches[0] : event;
if (!touch) {
return { x: 0, y: 0 };
}
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
};
const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) {
return;
}
isDrawing.current = true;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
if (!isDrawing.current || !canvasRef.current) {
return;
}
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
const stopDraw = () => {
isDrawing.current = false;
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
return { canvasRef, startDraw, draw, stopDraw, clearCanvas };
}

View file

@ -6,11 +6,6 @@
import { API_PATHS } from "@/api/api-paths";
import { apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
disableAuthBypassForSession,
getBypassAuthUser,
isAuthBypassEnabled,
} from "@/lib/auth/auth-bypass";
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type {
AuthUser,
@ -27,18 +22,10 @@ export async function login(credentials: LoginCredentials): Promise<AuthUser> {
}
export async function logout(): Promise<void> {
if (isAuthBypassEnabled()) {
disableAuthBypassForSession();
}
clearAuth();
}
export async function getSession(): Promise<AuthUser | null> {
if (isAuthBypassEnabled()) {
return getBypassAuthUser();
}
const user = getAuthUser();
if (!user || !isSessionValid(user)) {

View file

@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
import { queryKeys } from "@/infra/query-key/query-key";
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export function useSession(): UseQueryResult<AuthUser | null, Error> {
return useQuery({
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: (user) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
toast.success("Login successful!");
navigate("/");
},
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
return useMutation({
mutationFn: authApi.logout,
onSuccess: () => {
queryClient.setQueryData(queryKeys.auth.session(), null);
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
stopQueryBroadcast(queryClient);
toast.info("You have been logged out");
navigate("/login");
},
onError: (error: Error) => {
queryClient.setQueryData(queryKeys.auth.session(), null);
stopQueryBroadcast(queryClient);
toast.error(error.message || "Logout failed");
navigate("/login");
},

View file

@ -3,9 +3,16 @@ import type {
WorkOrderTypeApi,
} from "@/domain/work-orders/types/work-order-board";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import {
LIFECYCLE_STATUS_API,
WORK_ORDER_TYPE_API,
} from "@/domain/work-orders/types/work-order-board";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WizardWOStatus,
WOType,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -98,6 +105,10 @@ export function mapWizardDraftToBoardCreatePayload(
siteCode,
};
if (draft.status) {
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
}
if (draft.woNumber.trim()) {
payload.woNumber = draft.woNumber.trim();
}

View file

@ -1,10 +1,9 @@
import { env } from "@/lib/env";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
export function isWoWizardMockEnabled(): boolean {
return env.isDev;
}
export function shouldUseMockWoWizardCreate(locationId: string): boolean {
return isAuthBypassEnabled() || locationId.startsWith("mock-");
return env.isDev && locationId.startsWith("mock-");
}

View file

@ -64,6 +64,8 @@ export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Cancel
export interface WorkOrderBoardCreatePayload {
workOrderType: WorkOrderTypeApi;
siteCode: string;
/** Lifecycle status selected in the wizard; numeric board enum. */
lifecycleStatus?: LifecycleStatusApi;
woNumber?: string;
assignTo?: string;
dueDate?: string;

View file

@ -1,36 +0,0 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { env } from "@/lib/env";
const BYPASS_DISABLED_KEY = "auth-bypass-disabled";
export function isAuthBypassEnabled(): boolean {
if (!env.isDev || !env.authBypass) {
return false;
}
if (typeof window !== "undefined" && sessionStorage.getItem(BYPASS_DISABLED_KEY)) {
return false;
}
return true;
}
export function getBypassAuthUser(): AuthUser {
return {
token: "dev-bypass-token",
expiration: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: "dev-bypass@seahaven.test",
userRoles: "Admin",
phoneNumber: "",
fullname: "Dev Bypass User",
id: "dev-bypass",
};
}
export function disableAuthBypassForSession(): void {
if (typeof window === "undefined") {
return;
}
sessionStorage.setItem(BYPASS_DISABLED_KEY, "1");
}

View file

@ -1,5 +1,4 @@
export const env = {
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
isDev: import.meta.env.DEV,
authBypass: import.meta.env.VITE_AUTH_BYPASS === "true",
} as const;

View file

@ -0,0 +1,60 @@
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import type { QueryClient } from "@tanstack/react-query";
/** Prefix for same-origin BroadcastChannel names (suffixed with authenticated user id). */
export const QUERY_BROADCAST_CHANNEL_PREFIX = "seahaven-admin-query";
let unsubscribe: (() => void) | null = null;
let activeUserId: string | number | null = null;
/** Builds the account-scoped channel name for cross-tab QueryClient sync. */
export function queryBroadcastChannelForUser(userId: string | number): string {
return `${QUERY_BROADCAST_CHANNEL_PREFIX}:${String(userId)}`;
}
function disposeBroadcast(): void {
unsubscribe?.();
unsubscribe = null;
activeUserId = null;
}
function canUseBroadcast(): boolean {
return typeof window !== "undefined" && import.meta.env.MODE !== "test";
}
/**
* Starts (or switches) account-scoped QueryClient broadcast.
* Disposes any prior subscription; clears the cache when the authenticated user changes.
* No-op for the BroadcastChannel outside the browser and during Vitest runs.
*/
export function startQueryBroadcast(queryClient: QueryClient, userId: string | number): void {
if (activeUserId === userId) {
return;
}
const previousUserId = activeUserId;
disposeBroadcast();
if (previousUserId != null && previousUserId !== userId) {
queryClient.clear();
}
activeUserId = userId;
if (!canUseBroadcast()) {
return;
}
unsubscribe = broadcastQueryClient({
queryClient,
broadcastChannel: queryBroadcastChannelForUser(userId),
});
}
/**
* Tears down the broadcaster and clears the entire QueryClient cache (logout / 401).
*/
export function stopQueryBroadcast(queryClient: QueryClient): void {
disposeBroadcast();
queryClient.clear();
}

View file

@ -1,14 +1,26 @@
import { useMemo, type ReactNode } from "react";
import { useEffect, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
import { startQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const sessionQuery = useSession();
const loginMutation = useLogin();
const logoutMutation = useLogout();
const changePasswordMutation = useChangePassword();
const userId = sessionQuery.data?.id;
useEffect(() => {
if (userId == null) {
return;
}
startQueryBroadcast(queryClient, userId);
}, [queryClient, userId]);
const value = useMemo<AuthContextValue>(
() => ({
user: sessionQuery.data,

View file

@ -254,7 +254,7 @@ describe("VendorsListPage", () => {
expect(company).toBeInTheDocument();
expect(company).toHaveValue("Draft Company Name");
});
}, 10_000);
it("opens inline edit when the table Edit control is clicked", async () => {
setupDefaults();

View file

@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
@ -81,6 +82,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
beforeEach(() => {
onSave.mockReset();
onDateCompletedChange.mockReset();
vi.mocked(toast.error).mockReset();
});
function renderDialog(row: WorkOrderTableRow) {
@ -135,7 +137,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(row.status).toBe("Scheduled");
});
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
it("Generate emits completedDate before doc Yes without auto-setting lifecycle Completed", async () => {
const row = baseRow({ completedDate: "2026-07-01" });
const { patches } = renderDialog(row);
@ -156,11 +158,42 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(row.status).toBe("Scheduled");
});
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
const row = baseRow({ completedDate: "2026-07-01" });
it("Generate keeps Draft and skips docStatus Yes when Date Work Completed is blank", async () => {
const row = baseRow({ completedDate: "" });
renderDialog(row);
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"Date Work Completed is required to generate the document.",
);
});
expect(onDateCompletedChange).not.toHaveBeenCalled();
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
});
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
const row = baseRow({ completedDate: "2026-07-01" });
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={onSave}
onDateCompletedChange={onDateCompletedChange}
/>
</QueryClientProvider>,
);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
@ -169,6 +202,9 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
});
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalled();
});
});
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: {
info: { status: "Scheduled" },
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
media: [
{
id: "11",
@ -80,12 +80,24 @@ function baseRow(): WorkOrderTableRow {
title: "Emergency",
priority: "Sev 1",
primaryDispatchId: null,
rowVersion: "",
rowVersion: "ROW-VER==",
dispatchRowVersion: "",
flagColor: null,
};
}
function renderModal() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
}
describe("MediaUploadModal API wiring", () => {
beforeEach(() => {
uploadMutateAsync.mockReset();
@ -94,15 +106,7 @@ describe("MediaUploadModal API wiring", () => {
});
it("renders persisted media from board detail (refresh-safe)", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
renderModal();
await waitFor(() => {
expect(screen.getByText("before.jpg")).toBeInTheDocument();
@ -111,4 +115,22 @@ describe("MediaUploadModal API wiring", () => {
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
renderModal();
await waitFor(() => {
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
target: { value: "After" },
});
expect(categorizeMutate).toHaveBeenCalledWith({
mediaId: "11",
category: "After",
workOrderVersion: "DETAIL-VER==",
});
});
});

View file

@ -6,6 +6,23 @@ function makeFile(name = "photo.jpg") {
return new File(["bytes"], name, { type: "image/jpeg" });
}
function boardDetail(id: number) {
return {
info: { id },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
}
describe("useMediaUploadSession retry / cancel", () => {
it("retry upserts the same localId instead of appending a duplicate", async () => {
let rejectFirst!: (error: Error) => void;
@ -145,39 +162,9 @@ describe("useMediaUploadSession retry / cancel", () => {
}),
);
const detailA = {
info: { id: 1 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const detailB = {
info: { id: 2 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const { result, rerender } = renderHook(
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
{ initialProps: { detail: detailA } },
{ initialProps: { detail: boardDetail(1) } },
);
act(() => {
@ -196,7 +183,7 @@ describe("useMediaUploadSession retry / cancel", () => {
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
rerender({ detail: detailB });
rerender({ detail: boardDetail(2) });
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);

View file

@ -0,0 +1,62 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { SignatureCapture } from "@/components/common/signature-capture";
import { renderWithProviders } from "@/test/test-utils";
/**
* Mirrors how a consumer wires `isPending` from a mutation: the flag flips to
* `true` synchronously once `onSave` fires and only clears when the caller
* decides to (e.g. on mutation settle), never inside `SignatureCapture` itself.
*/
function SignatureCaptureHarness({ onSave }: { onSave: () => void }) {
const [isPending, setIsPending] = useState(false);
return (
<SignatureCapture
title="Customer Sign-off"
isPending={isPending}
onClose={vi.fn()}
onSave={() => {
setIsPending(true);
onSave();
}}
/>
);
}
async function fillTypedSignature(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByPlaceholderText("Enter signer's full name"), "Jane Doe");
await user.click(screen.getByRole("tab", { name: "Type" }));
await user.type(screen.getByPlaceholderText("Type your name"), "Jane Doe");
}
describe("SignatureCapture", () => {
it("disables Save Signature and closing controls while isPending", () => {
renderWithProviders(
<SignatureCapture title="Vendor Sign-off" isPending onClose={vi.fn()} onSave={vi.fn()} />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Close" })).toBeDisabled();
});
it("calls onSave exactly once when Save Signature is double-clicked before the mutation settles", async () => {
const user = userEvent.setup();
const handleSave = vi.fn();
renderWithProviders(<SignatureCaptureHarness onSave={handleSave} />, { withAuth: false });
await fillTypedSignature(user);
const saveButton = screen.getByRole("button", { name: "Save Signature" });
fireEvent.click(saveButton);
fireEvent.click(saveButton);
expect(handleSave).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
});
});

View file

@ -119,6 +119,7 @@ describe("workOrdersApi.createFromBoard", () => {
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
workOrderType: 2,
siteCode: "BK5",
lifecycleStatus: 1,
woNumber: "12345",
});
});

View file

@ -55,9 +55,25 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
workOrderType: 2,
siteCode: "BK5",
lifecycleStatus: 1,
});
});
it("maps wizard status to lifecycleStatus on create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
status: "Scheduled",
});
expect(payload.lifecycleStatus).toBe(3);
});
it("maps full wizard payload with structured service and notes fields", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
const disposeA = vi.fn();
const disposeB = vi.fn();
const broadcastQueryClient = vi.fn();
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
}));
describe("query broadcast account isolation", () => {
beforeEach(() => {
broadcastQueryClient.mockReset();
disposeA.mockReset();
disposeB.mockReset();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("does not call broadcastQueryClient when MODE is test", async () => {
vi.stubEnv("MODE", "test");
vi.resetModules();
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).not.toHaveBeenCalled();
});
it("scopes the BroadcastChannel to the authenticated user id", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
startQueryBroadcast(client, 42);
expect(QUERY_BROADCAST_CHANNEL_PREFIX).toBe("seahaven-admin-query");
expect(queryBroadcastChannelForUser(42)).toBe("seahaven-admin-query:42");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:42",
});
});
it("disposes the prior broadcaster, clears cache, and switches channel across accounts", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValueOnce(disposeA).mockReturnValueOnce(disposeB);
const { startQueryBroadcast, stopQueryBroadcast } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
client.setQueryData(["work-orders", "sensitive"], { from: "user-a" });
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-a",
});
startQueryBroadcast(client, "user-b");
expect(disposeA).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(client.getQueryData(["work-orders", "sensitive"])).toBeUndefined();
expect(broadcastQueryClient).toHaveBeenCalledTimes(2);
expect(broadcastQueryClient).toHaveBeenLastCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-b",
});
client.setQueryData(["vendors", "roster"], { from: "user-b" });
stopQueryBroadcast(client);
expect(disposeB).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(2);
expect(client.getQueryData(["vendors", "roster"])).toBeUndefined();
});
it("is a no-op when start is called again for the same user", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
startQueryBroadcast(client, "user-a");
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(disposeA).not.toHaveBeenCalled();
expect(clearSpy).not.toHaveBeenCalled();
});
});

View file

@ -1,11 +1,44 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
});
function localStorageIsUsable(): boolean {
try {
const probe = "__vitest_ls_probe__";
globalThis.localStorage.setItem(probe, "1");
const ok = globalThis.localStorage.getItem(probe) === "1";
globalThis.localStorage.removeItem(probe);
return ok;
} catch {
return false;
}
}
// Node 25+ / some harnesses lack a stable localStorage — provide an in-memory mock when needed.
if (!localStorageIsUsable()) {
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
get length() {
return store.size;
},
key: (index: number) => [...store.keys()][index] ?? null,
});
}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({

2
src/vite-env.d.ts vendored
View file

@ -3,8 +3,6 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
readonly VITE_AUTH_BYPASS?: string;
readonly VITE_WO_TABLE_MOCK?: string;
}
interface ImportMeta {

View file

@ -1,26 +0,0 @@
## Jira
- [SH-189](https://luby-us.atlassian.net/browse/SH-189)
## QA residual
- P0 done: duplicate dialog also on **wizard create** (not only table cell).
- P1 polish: Cancel / View Existing copy tweaks.
## Summary
- Show a confirmation dialog when inline WO number editing **or wizard create** detects a duplicate instead of navigating immediately to the existing work order.
- Dialog title: **Duplicate Work Order Number**; actions: **View existing** (opens the existing WO via slide-over route) and **Dismiss** (closes the dialog and keeps the current row editable).
- Duplicate scan logic shared via `find-duplicate-wo-number-row`.
## Test plan
- [ ] Edit a WO number in the table to match an existing row → dialog opens with the duplicate number in the message.
- [ ] Click **Dismiss** → dialog closes; edited row keeps its original number.
- [ ] Repeat duplicate entry → click **View existing** → navigates to `/workorders/{id}` for the existing row.
- [ ] Wizard create with duplicate WO number → same dialog; **View existing** closes wizard and opens `/workorders/{id}`.
## Out of scope
- Server-side uniqueness (422) handling
- SH provisional numbering