chore(work-orders): merge origin/dev and keep SH-184 Add-On with severity gates

This commit is contained in:
Arthur Bassi 2026-08-17 09:36:07 -03:00
commit 4a95da32d2
105 changed files with 3763 additions and 1386 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

@ -21,6 +21,7 @@ export function buildInlineDraftCreateInput(
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
type: draft.type || "PM",
severity: draft.severity,
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,

View file

@ -0,0 +1,39 @@
import { Tooltip } from "@mui/material";
import {
SEVERITY_LEVELS,
SEVERITY_TOKEN_COLOR,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
type SeverityBadgeProps = {
severity: WorkOrderSeverity;
onClick?: (e: React.MouseEvent) => void;
};
export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) {
const lvl = SEVERITY_LEVELS[severity];
const color = SEVERITY_TOKEN_COLOR[lvl.token];
const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`;
return (
<Tooltip title={title} enterDelay={400}>
<span
onClick={onClick}
role={onClick ? "button" : undefined}
className="inline-flex shrink-0 items-center whitespace-nowrap rounded leading-none"
style={{
fontSize: 10,
fontWeight: 600,
color,
border: `1px solid ${color}`,
padding: "1px 5px",
marginLeft: 4,
cursor: onClick ? "pointer" : "default",
}}
>
{lvl.label}
</span>
</Tooltip>
);
}

View file

@ -0,0 +1,86 @@
import { Text } from "@/components/ui/text";
import {
SEVERITY_LEVELS,
SEVERITY_TOKEN_COLOR,
WORK_ORDER_SEVERITIES,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
type SeverityOptionsListProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
};
export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) {
return (
<div className="grid gap-1.5">
{WORK_ORDER_SEVERITIES.map((n) => {
const lvl = SEVERITY_LEVELS[n];
const color = SEVERITY_TOKEN_COLOR[lvl.token];
const selected = value === n;
return (
<button
key={n}
type="button"
onClick={() => onSelect(n)}
className="flex items-start gap-2 rounded-md text-left transition-colors"
style={{
padding: "6px 10px",
border: `1px solid ${selected ? color : "var(--color-border)"}`,
background: selected ? "var(--color-bg-muted)" : "transparent",
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "1px 5px",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{lvl.label}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 12, color: "var(--color-text-primary)" }}>
{lvl.description}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{lvl.deadline}</span>
</div>
</button>
);
})}
</div>
);
}
type SeverityFieldProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
};
export function SeverityField({ value, onSelect }: SeverityFieldProps) {
return (
<div>
<Text
as="p"
variant="description"
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
>
Severity (from APM)
</Text>
<Text
as="p"
variant="description"
sx={{ fontSize: 11, color: "var(--color-text-muted)", margin: "0 0 8px" }}
>
Transcribe the severity already assigned on the client&apos;s ticket — this is not a
judgment call.
</Text>
<SeverityOptionsList value={value} onSelect={onSelect} />
</div>
);
}

View file

@ -1,25 +1,49 @@
import { useState } from "react";
import { Check } from "lucide-react";
import { Popover } from "@mui/material";
import {
typeNeedsSeverity,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { HoverChevron } from "./hover-chevron";
import { SeverityBadge } from "./severity-badge";
import { TypeSeverityModal } from "./type-severity-modal";
import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = {
value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string;
onSave: (v: WOType) => void;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
export function TypeCell({ value, q, onSave }: TypeCellProps) {
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
const [resetSeverity, setResetSeverity] = useState(false);
function pick(t: WOType) {
setAnchorEl(null);
const needsSeverity = typeNeedsSeverity(t);
const switchingSeverityType =
needsSeverity && value !== "" && typeNeedsSeverity(value) && t !== value;
if (needsSeverity && (severity == null || switchingSeverityType)) {
setModalSeed(t);
setResetSeverity(Boolean(switchingSeverityType && severity != null));
setModalOpen(true);
return;
}
onSave(t, needsSeverity ? severity : null);
}
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
return (
<>
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}
onClick={(e) => {
e.stopPropagation();
setModalSeed(value);
setResetSeverity(false);
setModalOpen(true);
}}
/>
)}
<HoverChevron />
</button>
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
<button
key={t}
type="button"
onClick={() => {
onSave(t);
setAnchorEl(null);
}}
onClick={() => pick(t)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button>
))}
</Popover>
<TypeSeverityModal
open={modalOpen}
onOpenChange={setModalOpen}
type={modalSeed}
severity={resetSeverity ? null : severity}
onSave={onSave}
/>
</>
);
}

View file

@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
typeNeedsSeverity,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { SeverityField } from "./severity-options-list";
import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeSeverityModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
type: WOType | "";
severity: WorkOrderSeverity | null;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
export function TypeSeverityModal({
open,
onOpenChange,
type,
severity,
onSave,
}: TypeSeverityModalProps) {
const [draftType, setDraftType] = useState<WOType | "">(type);
const [draftSeverity, setDraftSeverity] = useState<WorkOrderSeverity | null>(severity);
useEffect(() => {
if (open) {
setDraftType(type);
setDraftSeverity(severity);
}
}, [open, type, severity]);
const needsSeverity = typeNeedsSeverity(draftType);
function pickType(t: WOType) {
if (typeNeedsSeverity(t)) {
if (t !== draftType) {
setDraftSeverity(null);
}
setDraftType(t);
return;
}
onSave(t, null);
onOpenChange(false);
}
function confirm() {
if (!draftType || (needsSeverity && draftSeverity == null)) {
return;
}
onSave(draftType, needsSeverity ? draftSeverity : null);
onOpenChange(false);
}
const canConfirm = Boolean(draftType) && (!needsSeverity || draftSeverity != null);
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth="xs"
fullWidth
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: {
sx: { borderRadius: 2, background: "var(--color-bg-surface)" },
},
}}
>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
Type &amp; severity
</DialogTitle>
<DialogContent>
<div className="space-y-3">
<div>
<Text
as="p"
variant="description"
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
>
Type
</Text>
<div className="flex flex-wrap gap-1.5">
{WO_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => pickType(t)}
className="rounded-full transition-opacity hover:opacity-80"
style={{
padding: "4px 10px",
border: `1px solid ${
draftType === t ? "var(--color-primary)" : "var(--color-border)"
}`,
background: draftType === t ? "var(--color-primary-tint)" : "transparent",
}}
>
<WoTableTypeLabel type={t} />
</button>
))}
</div>
</div>
{Boolean(needsSeverity) && (
<SeverityField value={draftSeverity} onSelect={setDraftSeverity} />
)}
</div>
</DialogContent>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<Button variant="text" size="small" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="contained"
size="small"
onClick={confirm}
disabled={!canConfirm}
sx={
canConfirm
? undefined
: {
opacity: 0.5,
cursor: "not-allowed",
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
}
}
>
Save
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -37,6 +37,24 @@ type WoTableInlineRowCellsProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function InlineTypeSeverityCell({
draft,
onPatch,
}: {
draft: WorkOrderTableDraft;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
/>
</td>
);
}
function InlineMutedDashCell() {
return (
<td style={{ padding: "0 14px" }}>
@ -130,9 +148,7 @@ export function WoTableInlineRowCells({
onDuplicateFound={onDuplicateFound}
/>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
</td>
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell

View file

@ -84,7 +84,12 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<TypeCell value={row.type} q={search} onSave={(t) => onPatchRow({ type: t })} />
<TypeCell
value={row.type}
severity={row.severity}
q={search}
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
/>
</td>
<td
className="group/cell"

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

@ -68,6 +68,7 @@ export function NewWoWizard({
<WizardStepTypeSchedule
draft={wizard.draft}
users={users}
showErrors={wizard.showErrors}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound}
@ -82,6 +83,7 @@ export function NewWoWizard({
locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
showErrors={wizard.showErrors}
svcOpen={wizard.svcOpen}
onSvcOpenChange={wizard.setSvcOpen}
svcSearch={wizard.svcSearch}
@ -108,8 +110,15 @@ export function NewWoWizard({
onCancel={() => {
if (!wizard.isCreating) onOpenChange(false);
}}
onBack={() => wizard.setStep((s) => s - 1)}
onContinue={() => wizard.setStep((s) => s + 1)}
onBack={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s - 1);
}}
onContinue={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s + 1);
}}
onAttemptContinue={() => wizard.setShowErrors(true)}
onCreate={wizard.handleCreate}
isCreating={wizard.isCreating}
/>

View file

@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) {
});
const [svcOpen, setSvcOpen] = useState(false);
const [svcSearch, setSvcSearch] = useState("");
const [showErrors, setShowErrors] = useState(false);
useEffect(() => {
if (open) {
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setSvcOpen(false);
setSvcSearch("");
setShowErrors(false);
}
}, [open]);
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
setSvcOpen,
svcSearch,
setSvcSearch,
showErrors,
setShowErrors,
};
}

View file

@ -37,6 +37,7 @@ export function useNewWoWizardState({
}: UseNewWoWizardStateArgs) {
const mockFallback = isWoWizardMockEnabled();
const [step, setStep] = useState(1);
const [showErrors, setShowErrors] = useState(false);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT,
status: "Incomplete",
@ -81,6 +82,7 @@ export function useNewWoWizardState({
useEffect(() => {
if (open) {
setStep(1);
setShowErrors(false);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setPocAutoFilledFor("");
setDuplicateRow(null);
@ -119,6 +121,8 @@ export function useNewWoWizardState({
return {
step,
setStep,
showErrors,
setShowErrors,
draft,
duplicateRow,
setDuplicateRow,

View file

@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
import {
WIZARD_STEPS,
canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
@ -15,9 +17,24 @@ type WizardFooterProps = {
onBack: () => void;
onContinue: () => void;
onCreate: () => void;
onAttemptContinue?: () => void;
isCreating: boolean;
};
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
if (step === 1 && !canContinueWizardStep1(draft)) {
if (!isWizardWoNumberValid(draft.woNumber)) {
return "Enter an alphanumeric WO number, or leave blank for SH generation";
}
return "Select severity to continue";
}
if (step === 2 && !canContinueWizardStep2(draft)) {
if (!draft.locationId) return "Pick a site to continue";
return "Enter POC name and phone to continue";
}
return null;
}
export function WizardFooter({
step,
draft,
@ -25,11 +42,12 @@ export function WizardFooter({
onBack,
onContinue,
onCreate,
onAttemptContinue,
isCreating,
}: WizardFooterProps) {
const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft);
const canContinue1 = canContinueWizardStep1(draft);
const hint = stepHint(step, draft);
return (
<div
@ -60,20 +78,25 @@ export function WizardFooter({
)}
<div className="flex flex-1 justify-center">
{step === 1 && !canContinue1 && (
{Boolean(hint) && (
<span
className="inline-flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Lightbulb size={13} /> Pick a type to continue
<Lightbulb size={13} /> {hint}
</span>
)}
</div>
{step < WIZARD_STEPS.length ? (
<Button
disabled={!stepValid}
onClick={onContinue}
onClick={() => {
if (!stepValid) {
onAttemptContinue?.();
return;
}
onContinue();
}}
endIcon={<ChevronRight size={14} />}
sx={{
background: "var(--color-primary)",
@ -81,17 +104,23 @@ export function WizardFooter({
height: 36,
fontSize: 13,
fontWeight: 500,
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
...(!stepValid ? { opacity: 0.7 } : {}),
}}
>
Continue
</Button>
) : (
<Button
disabled={!canCreate || isCreating}
disabled={isCreating}
loading={isCreating}
loadingText="Creating…"
onClick={onCreate}
onClick={() => {
if (!canCreate) {
onAttemptContinue?.();
return;
}
onCreate();
}}
startIcon={!isCreating ? <Check size={14} /> : undefined}
sx={{
background: "var(--color-primary)",
@ -99,7 +128,7 @@ export function WizardFooter({
height: 36,
fontSize: 13,
fontWeight: 500,
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
...(!canCreate ? { opacity: 0.7 } : {}),
}}
>
Create work order

View file

@ -0,0 +1,31 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import { SeverityOptionsList } from "@/app/(protected)/workorders/_components/list/table/cells/severity-options-list";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardSeverityPickerProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
showError: boolean;
};
export function WizardSeverityPicker({ value, onSelect, showError }: WizardSeverityPickerProps) {
return (
<div>
<WizLabel required>Severity (from APM)</WizLabel>
<Text
as="p"
variant="description"
sx={{ fontSize: 11, color: "var(--muted-foreground)", margin: "0 0 8px" }}
>
Transcribe the severity already assigned on the client&apos;s ticket.
</Text>
<SeverityOptionsList value={value} onSelect={onSelect} />
{Boolean(showError && value == null) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
Severity is required for Emergency and Reactive work orders.
</Text>
)}
</div>
);
}

View file

@ -4,17 +4,24 @@ import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft;
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
};
export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) {
export function WizardStepLocationPocFields({
draft,
showErrors = false,
onUpdate,
}: WizardStepLocationPocFieldsProps) {
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return (
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<WizLabel required>Point of contact</WizLabel>
<Text
as="p"
variant="description"
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(showErrors && pocIncomplete) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
POC name and phone are required.
</Text>
)}
<textarea
value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)}

View file

@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
locationDetailLoading?: boolean;
locationDetailError?: boolean;
services: ServiceOption[];
showErrors?: boolean;
svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void;
svcSearch: string;
@ -31,6 +32,7 @@ export function WizardStepLocationService({
locationDetailLoading,
locationDetailError,
services,
showErrors = false,
svcOpen,
onSvcOpenChange,
svcSearch,
@ -57,7 +59,7 @@ export function WizardStepLocationService({
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
/>
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} />
<WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
</>
)}

View file

@ -1,7 +1,16 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import {
WO_TYPE_META,
WO_TYPES,
isWizardWoNumberValid,
type WOType,
} from "@/domain/work-orders/types/work-order-wizard";
import {
typeNeedsSeverity,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
@ -15,6 +24,7 @@ import {
WizLabel,
WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker";
import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react";
@ -25,6 +35,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
Overdue: Clock,
};
function nextSeverityForType(
currentType: WOType | "",
nextType: WOType,
currentSeverity: WorkOrderSeverity | null,
): WorkOrderSeverity | null {
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
return null;
}
return currentSeverity;
}
function WoTypePicker({
selected,
onSelect,
@ -112,9 +133,84 @@ function WoTypePicker({
);
}
type WizardTypeScheduleDatesProps = {
draft: WorkOrderWizardDraft;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
isAddOnOverride: false,
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
isAddOnOverride: false,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
);
}
type WizardStepTypeScheduleProps = {
draft: WorkOrderWizardDraft;
users: WorkOrderUser[];
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void;
@ -160,32 +256,57 @@ function WizardAddOnOverrideField({
export function WizardStepTypeSchedule({
draft,
users,
showErrors = false,
onUpdate,
onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type);
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return (
<div className="space-y-6">
<div>
<WizLabel required>Type of WO</WizLabel>
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
<WizLabel optional>Type of WO</WizLabel>
<WoTypePicker
selected={draft.type}
onSelect={(t) => {
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
});
}}
/>
</div>
{Boolean(needsSeverity) && (
<WizardSeverityPicker
value={draft.severity}
showError={showErrors}
onSelect={(severity) => onUpdate("severity", severity)}
/>
)}
<div>
<WizLabel optional>WO Number</WizLabel>
<input
type="text"
inputMode="numeric"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 12345678901"
placeholder="Leave blank for SH provisional number"
maxLength={20}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
{Boolean(woNumberError) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
WO number must be alphanumeric.
</Text>
)}
</div>
<div>
@ -216,69 +337,7 @@ export function WizardStepTypeSchedule({
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
isAddOnOverride: false,
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
isAddOnOverride: false,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
{Boolean(!hasWizardSchedule(draft)) && (
<WizardAddOnOverrideField

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

@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
if (vendorNotes) {
payload.vendorNotes = vendorNotes;
}
if (draft.severity != null) {
payload.severity = draft.severity;
}
}

View file

@ -18,6 +18,12 @@ type ScalarBoardOpRule = {
/** One row per scalar patch field, in emission order. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
// Severity before type so Emergency/Reactive never land without required severity.
{
key: "severity",
field: "severity",
toValue: (value) => (value == null ? "" : String(value)),
},
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },

View file

@ -1,8 +1,9 @@
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type {
WOType,
WizardWOStatus,
WorkOrderWizardDraft,
import {
effectiveWizardType,
type WOType,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@ -65,13 +66,14 @@ export function mapTypeToPriority(type: WOType | ""): string {
}
function buildTitle(draft: WorkOrderWizardDraft): string {
const typeLabel = effectiveWizardType(draft.type);
if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
return `${typeLabel} — #${draft.woNumber}`;
}
if (draft.locationName) {
return `${draft.type || "Work Order"} — ${draft.locationName}`;
return `${typeLabel} — ${draft.locationName}`;
}
return draft.type || "Work Order";
return typeLabel;
}
function buildDescription(draft: WorkOrderWizardDraft): string {
@ -102,7 +104,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
workOrderNumber: draft.woNumber,
title: buildTitle(draft),
locationId: draft.locationId,
priority: mapTypeToPriority(draft.type),
priority: mapTypeToPriority(effectiveWizardType(draft.type)),
status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(),
dueDate: draft.dueDate,
@ -117,6 +119,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
trade: "",
subTrade: "",
vendorNTE: "",
source: draft.type,
source: effectiveWizardType(draft.type),
};
}

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";
@ -79,7 +86,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
export function mapWizardDraftToBoardCreatePayload(
draft: WorkOrderWizardDraft,
): WorkOrderBoardCreatePayload {
const workOrderType = mapWizardTypeToApi(draft.type);
const workOrderType = mapWizardTypeToApi(draft.type || "PM");
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
@ -106,6 +113,10 @@ export function mapWizardDraftToBoardCreatePayload(
}),
};
if (draft.status) {
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
}
if (draft.woNumber.trim()) {
payload.woNumber = draft.woNumber.trim();
}

View file

@ -9,6 +9,7 @@ import type {
CompletionDocStatus,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -274,6 +275,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
woNumber,
site: site || location.slice(0, 8),
type: readWoType(item),
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
dispatcherId,
dispatcherName,
scheduledOn,

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;
@ -96,6 +98,8 @@ export interface WorkOrderBoardCreatePayload {
pocNotes?: string;
techPhone?: string;
vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number;
}
/** Payload for PATCH /workorders/{id}/board (one field per request). */
@ -125,7 +129,8 @@ export type WorkOrderBoardEditableField =
| "pm"
| "serviceNotes"
| "extraServices"
| "flagColor";
| "flagColor"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
export interface WorkOrderBoardRowApi {
@ -152,6 +157,8 @@ export interface WorkOrderBoardRowApi {
color?: string | null;
/** Board flag color (#RRGGBB) or null when cleared. */
flagColor?: string | null;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number | null;
dueDate?: string | null;
scheduledDate?: string | null;
/** ISO date when work was completed; optional on list rows until BE confirms. */

View file

@ -0,0 +1,75 @@
export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5;
export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5];
/** Types that require an APM severity transcription. */
export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const;
export type SeverityWoType = (typeof SEVERITY_TYPES)[number];
export type SeverityToken = "critical" | "warning" | "safe";
export const SEVERITY_LEVELS: Record<
WorkOrderSeverity,
{
label: string;
description: string;
deadline: string;
afterHours: string;
token: SeverityToken;
}
> = {
1: {
label: "SEV 1",
description: "Business Critical Function Down",
deadline: "Accept: 15 min · Respond: 2 hrs",
afterHours: "Yes",
token: "critical",
},
2: {
label: "SEV 2",
description: "Business Critical Function Impaired",
deadline: "Accept: 30 min · Respond: 4 hrs",
afterHours: "Yes",
token: "critical",
},
3: {
label: "SEV 3",
description: "Group Productivity Impaired",
deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)",
afterHours: "Approval required",
token: "warning",
},
4: {
label: "SEV 4",
description: "Individual Productivity Impaired",
deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)",
afterHours: "No",
token: "safe",
},
5: {
label: "SEV 5",
description: "Productivity not immediately affected",
deadline: "Accept: 12 hrs · Respond: 72 hrs",
afterHours: "No",
token: "safe",
},
};
export const SEVERITY_TOKEN_COLOR: Record<SeverityToken, string> = {
critical: "var(--sla-critical, #DC2626)",
warning: "var(--sla-warning, #D97706)",
safe: "var(--sla-safe, #16A34A)",
};
export function typeNeedsSeverity(type: string): boolean {
return (SEVERITY_TYPES as readonly string[]).includes(type);
}
export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
return n;
}
return null;
}

View file

@ -1,4 +1,5 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
woNumber: string;
site: string;
type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
@ -62,6 +65,7 @@ export interface WorkOrderTableDraft {
site: string;
woNumber: string;
type: WOType | "";
severity: WorkOrderSeverity | null;
dispatcherId: string;
scheduledOn: string;
schedNotes: string;
@ -91,6 +95,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
woNumber: "",
type: "",
severity: null,
dispatcherId: "",
scheduledOn: "",
schedNotes: "",

View file

@ -1,3 +1,7 @@
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
export type { WorkOrderSeverity } from "./work-order-severity";
/** Selectable business types — Add-On is an indicator (`isAddOn`), not a type. */
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Overdue"] as const;
@ -25,6 +29,8 @@ export interface WorkOrderWizardDraft {
siteCode: string;
woNumber: string;
type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
assigneeId: string;
assigneeName: string;
scheduledOn: string;
@ -65,6 +71,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
siteCode: "",
woNumber: "",
type: "",
severity: null,
assigneeId: "",
assigneeName: "",
scheduledOn: "",
@ -89,13 +96,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
isAddOnOverride: false,
};
/** WO number is optional; when provided must be digits only, 1–20 characters. */
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
export function isWizardWoNumberValid(woNumber: string): boolean {
const trimmed = woNumber.trim();
if (!trimmed) {
return true;
}
return /^\d{1,20}$/.test(trimmed);
return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
}
/** Type is optional at creation; empty draft type defaults to PM. */
export function effectiveWizardType(type: WOType | ""): WOType {
return type || "PM";
}
export function wizardNeedsSeverity(draft: WorkOrderWizardDraft): boolean {
return typeNeedsSeverity(draft.type);
}
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
@ -162,11 +178,22 @@ export const TIME_SLOTS: string[] = (() => {
})();
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber);
if (!isWizardWoNumberValid(draft.woNumber)) {
return false;
}
if (wizardNeedsSeverity(draft) && draft.severity == null) {
return false;
}
return true;
}
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
}
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
}
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
@ -174,7 +201,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
return canContinueWizardStep1(draft);
}
if (step === 2) {
return Boolean(draft.locationId && draft.siteCode);
return canContinueWizardStep2(draft);
}
return true;
}

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";
@ -27,11 +28,12 @@ vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"
const SCHEDULED_ON = "2026-06-01";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON,
@ -71,7 +73,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
return {
...defaults,
...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
};
}
@ -82,6 +88,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
beforeEach(() => {
onSave.mockReset();
onDateCompletedChange.mockReset();
vi.mocked(toast.error).mockReset();
});
function renderDialog(row: WorkOrderTableRow) {
@ -136,7 +143,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);
@ -157,11 +164,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 }));
@ -170,6 +208,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",
@ -45,6 +45,7 @@ function baseRow(): WorkOrderTableRow {
woNumber: "WO-7",
site: "SEA1",
type: "Emergency",
severity: 1,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
@ -80,13 +81,25 @@ function baseRow(): WorkOrderTableRow {
title: "Emergency",
priority: "Sev 1",
primaryDispatchId: null,
rowVersion: "",
rowVersion: "ROW-VER==",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
}
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();
@ -95,15 +108,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();
@ -112,4 +117,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

@ -48,7 +48,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>,
);
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, {
target: { value: "12345678901" },
});
@ -77,7 +77,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>,
);
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, {
target: { value: "98765432109" },
});

View file

@ -15,11 +15,12 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
@ -59,7 +60,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
return {
...defaults,
...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
};
}

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

@ -10,6 +10,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "100",
site: "SITE",
type: "Emergency",
severity: null,
dispatcherId: "d1",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",

View file

@ -14,6 +14,7 @@ const unscheduledRow: WorkOrderTableRow = {
woNumber: "WO-42",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",

View file

@ -53,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",

View file

@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
woNumber: "WO-77",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
@ -217,9 +218,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
expect(
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));

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",
isAddOn: false,
});

View file

@ -3,8 +3,10 @@ import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-
import {
EMPTY_WIZARD_DRAFT,
canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
} from "@/domain/work-orders/types/work-order-wizard";
describe("mapWizardDraftToFormValues", () => {
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
});
describe("wizard step validation", () => {
it("blocks step 1 continue without type", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
it("allows step 1 continue without a woNumber for backend SH generation", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true);
});
it("allows step 1 when type is set without woNumber", () => {
it("allows step 1 without type when woNumber is set (type defaults to PM)", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
});
it("allows step 1 when woNumber is missing even with type", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(1, draft)).toBe(true);
});
it("blocks step 1 for Emergency without severity", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "Emergency" as const,
woNumber: "12345",
};
expect(canContinueWizardStep1(draft)).toBe(false);
});
it("allows step 1 for Emergency with severity", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "Emergency" as const,
woNumber: "12345",
severity: 2 as const,
};
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("blocks step 2 without POC name/phone", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345",
locationId: "99",
siteCode: "BK5",
};
expect(canContinueWizardStep2(draft)).toBe(false);
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows step 2 when site and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canContinueWizardStep2(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("blocks create without siteCode", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows create when type and siteCode are set (woNumber optional)", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
locationId: "99",
siteCode: "BK5",
};
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("allows create when type, woNumber, and siteCode are set", () => {
it("allows create when type, woNumber, site, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901234567890",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("allows step 1 when woNumber is empty", () => {
it("allows provisional SH-prefixed woNumber", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
woNumber: "SH12345",
};
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("accepts blank woNumber for backend SH generation", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "",
};
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("blocks step 1 when woNumber is non-numeric", () => {
it("accepts whitespace-only woNumber for backend SH generation", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "abc",
woNumber: " ",
};
expect(canContinueWizardStep1(draft)).toBe(false);
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("rejects non-alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("!!!")).toBe(false);
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false);
});
it("accepts alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("ABC123")).toBe(true);
});
});

View file

@ -55,6 +55,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
siteCode: "BK5",
isAddOn: false,
lifecycleStatus: 1,
});
});
@ -74,11 +76,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
it("sets isAddOn from manual override when schedule is missing", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
isAddOnOverride: true,
});
@ -115,6 +114,17 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.isAddOn).toBe(true);
});
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,
@ -308,14 +318,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.workOrderType).toBe(3);
});
it("throws when type is missing", () => {
expect(() =>
mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
siteCode: "BK5",
}),
).toThrow("Work order type is required");
it("defaults empty type to PM", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
siteCode: "BK5",
});
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
});
it("throws when siteCode cannot be resolved", () => {

View file

@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
{ field: "flagColor", value: "" },
]);
expect(expandTablePatchToBoardFieldOps({ severity: 3 })).toEqual([
{ field: "severity", value: "3" },
]);
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
{ field: "severity", value: "" },
]);
});
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {

View file

@ -147,11 +147,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
docStatus: 1,
dayGroup: "wednesday",
flagColor: "#EF4444",
severity: 2,
});
expect(row.woNumber).toBe("10000001235");
expect(row.site).toBe("BK5");
expect(row.type).toBe("Reactive");
expect(row.severity).toBe(2);
expect(row.status).toBe("Scheduled");
expect(row.scheduledOn).toBe("2026-07-15");
expect(row.dueDate).toBe("2026-07-18");

View file

@ -17,6 +17,7 @@ const BASE_ROW = {
id: 99,
rowVersion: "AAAAAAA=",
completedDate: "",
severity: null,
} as WorkOrderTableRow;
describe("runWorkOrderTablePatch completedDate", () => {

View file

@ -53,11 +53,12 @@ const BASE_SERVER: ServerState = {
docStatus: "No",
};
const BASE_ROW = {
const BASE_ROW: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "d1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
@ -97,7 +98,7 @@ const BASE_ROW = {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
} as WorkOrderTableRow;
};
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState;

View file

@ -12,6 +12,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "1001",
site: "SITE-A",
type: "PM",
severity: null,
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",

View file

@ -34,6 +34,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
woNumber: "1001",
site: "SITE-A",
type: "PM",
severity: null,
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",

View file

@ -23,6 +23,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "12345678901",
site: "SITE01",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",

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