chore(work-orders): merge origin/dev into SH-121 advanced filters

This commit is contained in:
Arthur Bassi 2026-08-17 14:21:09 -03:00
commit 260f57ff23
129 changed files with 5338 additions and 1898 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
<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}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
<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,10 +7,12 @@ type CompDocProcedureListProps = {
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => (
{proc.steps.map((st, i) => {
const subSteps = st.sub ?? [];
return (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{st.sub != null && (
{subSteps.length > 0 && (
<ol
style={{
listStyle: "lower-alpha",
@ -18,7 +20,7 @@ export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
margin: "4px 0 0",
}}
>
{st.sub.map((x, j) => (
{subSteps.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
@ -26,7 +28,8 @@ export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
</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`}
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
<CompDocDetailsSection
row={row}
site={site}
editAddress={editAddress}
onAddressChange={onAddressChange}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
/>
<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>
<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,27 +1,17 @@
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>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
</div>
@ -30,16 +20,6 @@ export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTa
{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)}
onConfirm={controller.handleUpliftConfirm}
isPending={
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending
onClose={() => {
if (!isDecidingUplift) {
controller.setUpliftAction(null);
}
}}
onConfirm={controller.handleUpliftConfirm}
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" }}>
@ -81,9 +99,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

@ -83,7 +83,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

@ -0,0 +1,71 @@
export type MiniCalendarCellStyle = {
bg: string;
fg: string;
radius: number | string;
filled: boolean;
};
function rangeBounds(rangeStart: string, rangeEnd: string): { lo: string; hi: string } {
if (!rangeEnd) {
return { lo: rangeStart, hi: rangeStart };
}
return rangeStart <= rangeEnd
? { lo: rangeStart, hi: rangeEnd }
: { lo: rangeEnd, hi: rangeStart };
}
function rangeCellStyle(
iso: string,
rangeStart: string,
rangeEnd: string,
): MiniCalendarCellStyle | null {
const { lo, hi } = rangeBounds(rangeStart, rangeEnd);
const filledStyle = { fg: "var(--color-primary-foreground)", bg: "var(--color-primary)" };
if (lo === hi) {
return iso === lo ? { ...filledStyle, radius: 4, filled: true } : null;
}
if (iso === lo) {
return { ...filledStyle, radius: "4px 0 0 4px", filled: true };
}
if (iso === hi) {
return { ...filledStyle, radius: "0 4px 4px 0", filled: true };
}
if (iso > lo && iso < hi) {
return { bg: "var(--color-primary-tint)", fg: "var(--color-primary)", radius: 0, filled: true };
}
return null;
}
/** Resolves the visual state of a single day cell for both single-value and range selection. */
export function getMiniCalendarCellStyle(args: {
iso: string;
value: string;
isToday: boolean;
range?: boolean;
rangeStart?: string;
rangeEnd?: string;
}): MiniCalendarCellStyle {
const { iso, value, isToday, range, rangeStart = "", rangeEnd = "" } = args;
const defaultStyle: MiniCalendarCellStyle = {
bg: "transparent",
fg: isToday ? "var(--color-primary)" : "var(--color-text-primary)",
radius: 4,
filled: false,
};
if (range && rangeStart) {
return rangeCellStyle(iso, rangeStart, rangeEnd) ?? defaultStyle;
}
if (iso === value) {
return {
bg: "var(--color-primary)",
fg: "var(--color-primary-foreground)",
radius: 4,
filled: true,
};
}
return defaultStyle;
}

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { getMiniCalendarCellStyle } from "@/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style";
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
@ -7,10 +8,21 @@ const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
type MiniCalendarProps = {
value: string;
onChange: (iso: string) => void;
/** Range-selection mode — parent owns the click logic. */
range?: boolean;
rangeStart?: string;
rangeEnd?: string;
};
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
export function MiniCalendar({
value,
onChange,
range,
rangeStart = "",
rangeEnd = "",
}: MiniCalendarProps) {
const initRaw = range ? rangeStart || value : value;
const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im);
@ -81,8 +93,16 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
return <span key={i} />;
}
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
const selected = iso === value;
const isToday = iso === today;
const { bg, fg, radius, filled } = getMiniCalendarCellStyle({
iso,
value,
isToday,
range,
rangeStart,
rangeEnd,
});
return (
<button
key={i}
@ -91,16 +111,12 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
style={{
height: 26,
borderRadius: 4,
fontWeight: selected || isToday ? 600 : 400,
background: selected ? "var(--color-primary)" : "transparent",
color: selected
? "var(--color-primary-foreground)"
: isToday
? "var(--color-primary)"
: "var(--color-text-primary)",
borderRadius: radius,
fontWeight: filled || isToday ? 600 : 400,
background: filled ? bg : undefined,
color: fg,
border:
isToday && !selected
isToday && !filled
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
: undefined,
}}

View file

@ -1,16 +1,18 @@
import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useNewWoWizardState } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state";
import { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
type NewWoWizardProps = {
open: boolean;
@ -20,46 +22,6 @@ type NewWoWizardProps = {
onViewExistingWo?: (row: WorkOrderTableRow) => void;
};
function WizardStepHeader({ step }: { step: number }) {
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--color-primary)",
}}
>
Step {step} of {WIZARD_STEPS.length}
</div>
<Text
variant="title"
sx={{
fontWeight: 700,
fontSize: 20,
color: "var(--foreground)",
marginTop: "6px",
}}
>
{WIZARD_STEPS[step - 1].title}
</Text>
<Text
variant="description"
sx={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: "4px",
}}
>
{WIZARD_STEPS[step - 1].subtitle}
</Text>
</div>
);
}
export function NewWoWizard({
open,
onOpenChange,
@ -67,13 +29,13 @@ export function NewWoWizard({
existingRows = [],
onViewExistingWo,
}: NewWoWizardProps) {
const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange });
const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
return (
<>
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={wizard.handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -85,13 +47,15 @@ export function NewWoWizard({
maxWidth: 768,
height: 680,
maxHeight: 680,
pointerEvents: wizard.isCreating ? "none" : undefined,
opacity: wizard.isCreating ? 0.85 : 1,
},
},
}}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 flex-1" aria-busy={wizard.isCreating || undefined}>
<WizardSidebar step={wizard.step} draft={wizard.draft} />
<div
@ -103,7 +67,8 @@ export function NewWoWizard({
{wizard.step === 1 && (
<WizardStepTypeSchedule
draft={wizard.draft}
users={wizard.wizardUsers}
users={users}
showErrors={wizard.showErrors}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound}
@ -115,7 +80,14 @@ export function NewWoWizard({
draft={wizard.draft}
locations={wizard.locations}
locationDetail={wizard.locationDetail}
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}
onSvcSearchChange={wizard.setSvcSearch}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
@ -135,9 +107,18 @@ export function NewWoWizard({
<WizardFooter
step={wizard.step}
draft={wizard.draft}
onCancel={() => onOpenChange(false)}
onBack={() => wizard.setStep((s) => s - 1)}
onContinue={() => wizard.setStep((s) => s + 1)}
onCancel={() => {
if (!wizard.isCreating) onOpenChange(false);
}}
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

@ -0,0 +1,93 @@
import { useEffect } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
type UseNewWoWizardControllerArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
existingRows: WorkOrderTableRow[];
};
/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
export function useNewWoWizardController({
open,
onOpenChange,
existingRows,
}: UseNewWoWizardControllerArgs) {
const draftState = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draftState.draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draftState.draft.pm.trim() || undefined,
locationDetail?.zipCode,
);
const { notifyLocationChanged } = useWizardPocAutofill(
open,
draftState.draft.locationId,
locationDetail,
draftState.setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
draftState.patchDraft(patch);
if (patch.locationId !== undefined) {
notifyLocationChanged(patch.locationId);
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,
draft: draftState.draft,
existingRows,
createMutation,
});
useEffect(() => {
if (open) {
setDuplicateRow(null);
}
}, [open, setDuplicateRow]);
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return {
...draftState,
patchDraft,
locations,
problems,
locationDetail,
locationDetailLoading,
locationDetailError,
vendors,
duplicateRow,
setDuplicateRow,
handleDuplicateFound,
handleCreate,
isCreating,
handleClose,
};
}

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
/** Owns the step/draft/service-panel state for the new-work-order wizard, resetting on open. */
export function useNewWoWizardDraft(open: boolean) {
const [step, setStep] = useState(1);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT,
status: "Incomplete",
});
const [svcOpen, setSvcOpen] = useState(false);
const [svcSearch, setSvcSearch] = useState("");
const [showErrors, setShowErrors] = useState(false);
useEffect(() => {
if (open) {
setStep(1);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setSvcOpen(false);
setSvcSearch("");
setShowErrors(false);
}
}, [open]);
const setField = <K extends keyof WorkOrderWizardDraft>(
key: K,
value: WorkOrderWizardDraft[K],
) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
};
return {
step,
setStep,
draft,
setDraft,
setField,
patchDraft,
svcOpen,
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

@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
type UseWizardDateFieldStateArgs = {
open: boolean;
value: string;
scheduleEnd: string;
allowRange: boolean;
allowWeekMode?: boolean;
targetWeek: string;
schedMode?: WizardSchedMode;
};
function resolveInitialMode({
allowRange,
schedMode,
allowWeekMode,
value,
targetWeek,
}: Pick<
UseWizardDateFieldStateArgs,
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
>): WizardSchedMode {
if (allowRange && schedMode === "range") {
return "range";
}
if (allowWeekMode && !value && targetWeek) {
return "week";
}
return "date";
}
/** Owns the popover-local mode/range selection state for `WizardDateField`. */
export function useWizardDateFieldState({
open,
value,
scheduleEnd,
allowRange,
allowWeekMode,
targetWeek,
schedMode,
}: UseWizardDateFieldStateArgs) {
const initialMode = resolveInitialMode({
allowRange,
schedMode,
allowWeekMode,
value,
targetWeek,
});
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
const [rangeStart, setRangeStart] = useState(value || "");
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
useEffect(() => {
if (!open) {
return;
}
// Keep draft props as source of truth while open so parent clears
// (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
}, [open, value, scheduleEnd]);
useEffect(() => {
if (open) {
setCellMode(initialMode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
}, [open]);
function handleRangeClick(iso: string) {
if (!rangeStart || (rangeStart && rangeEnd)) {
setRangeStart(iso);
setRangeEnd("");
return;
}
if (iso < rangeStart) {
setRangeStart(iso);
setRangeEnd(rangeStart);
} else {
setRangeEnd(iso);
}
}
return { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick };
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void;
/**
* Auto-fills POC name/phone from the fetched location, once per selected location.
* Never clobbers fields the dispatcher already typed while detail was loading
* (prototype: autofill from site records, then edit if outdated).
*/
export function useWizardPocAutofill(
open: boolean,
locationId: string,
locationDetail: Location | undefined,
setDraft: SetDraft,
) {
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
useEffect(() => {
if (open) {
setPocAutoFilledFor("");
}
}, [open]);
useEffect(() => {
if (!locationId || !locationDetail || pocAutoFilledFor === locationId) {
return;
}
setDraft((current) => ({
...current,
pocName: current.pocName.trim()
? current.pocName
: (locationDetail.contact ?? current.pocName),
pocPhone: current.pocPhone.trim()
? current.pocPhone
: (locationDetail.phone ?? current.pocPhone),
}));
setPocAutoFilledFor(locationId);
}, [locationId, locationDetail, pocAutoFilledFor, setDraft]);
// Always clear the seed guard on a site pick (including re-selecting the
// same location). Prototype onPick re-seeds pocName/pocPhone from the site
// every time; if we keep the guard keyed by the same id, clearing the fields
// leaves them blank because autofill will not re-run.
function notifyLocationChanged(_newLocationId: string) {
setPocAutoFilledFor("");
}
return { notifyLocationChanged };
}

View file

@ -0,0 +1,68 @@
import { CalendarDays, CalendarIcon, CalendarRange, ChevronDown } from "lucide-react";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
type WizardDateFieldTriggerProps = {
onAnchor: (element: HTMLElement) => void;
open: boolean;
label: string;
showWeek: boolean;
showRange: boolean;
hasValue: boolean;
};
function DateFieldIcon({ showRange, showWeek }: { showRange: boolean; showWeek: boolean }) {
const style = { color: "var(--muted-foreground)" };
if (showRange) {
return <CalendarRange size={16} className="shrink-0" style={style} />;
}
if (showWeek) {
return <CalendarDays size={16} className="shrink-0" style={style} />;
}
return <CalendarIcon size={16} className="shrink-0" style={style} />;
}
export function WizardDateFieldTrigger({
onAnchor,
open,
label,
showWeek,
showRange,
hasValue,
}: WizardDateFieldTriggerProps) {
return (
<MenuAnchorButton
onAnchor={onAnchor}
open={open}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
<DateFieldIcon showRange={showRange} showWeek={showWeek} />
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color:
hasValue || showWeek || showRange ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{label}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
);
}

View file

@ -1,10 +1,12 @@
import { useState } from "react";
import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WizardDateFieldTrigger } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger";
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
import { fmtDate, fmtDateRange, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WizardDateFieldProps = {
@ -14,8 +16,29 @@ type WizardDateFieldProps = {
allowWeekMode?: boolean;
targetWeek?: string;
onChangeWeek?: (weekStart: string) => void;
scheduleEnd?: string;
schedMode?: WizardSchedMode;
onChangeRange?: (start: string, end: string) => void;
};
function fieldLabel(args: {
showRange: boolean;
showWeek: boolean;
value: string;
scheduleEnd: string;
targetWeek: string;
placeholder: string;
}): string {
const { showRange, showWeek, value, scheduleEnd, targetWeek, placeholder } = args;
if (showRange) {
return fmtDateRange(value, scheduleEnd);
}
if (showWeek) {
return fmtWeekRange(targetWeek);
}
return value ? fmtDate(value) : placeholder;
}
export function WizardDateField({
value,
placeholder,
@ -23,60 +46,44 @@ export function WizardDateField({
allowWeekMode,
targetWeek = "",
onChangeWeek,
scheduleEnd = "",
schedMode,
onChangeRange,
}: WizardDateFieldProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode && !value && targetWeek ? "week" : "date",
const allowRange = Boolean(allowWeekMode && onChangeRange);
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
{
open,
value,
scheduleEnd,
allowRange,
allowWeekMode,
targetWeek,
schedMode,
},
);
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
const modes: WizardSchedMode[] = allowRange
? ["week", "date", "range"]
: allowWeekMode
? ["week", "date"]
: [];
return (
<>
<MenuAnchorButton
<WizardDateFieldTrigger
onAnchor={setAnchor}
open={open}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
{showWeek ? (
<CalendarDays
size={16}
className="shrink-0"
style={{ color: "var(--muted-foreground)" }}
label={fieldLabel({ showRange, showWeek, value, scheduleEnd, targetWeek, placeholder })}
showWeek={showWeek}
showRange={showRange}
hasValue={Boolean(value)}
/>
) : (
<CalendarIcon
size={16}
className="shrink-0"
style={{ color: "var(--muted-foreground)" }}
/>
)}
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value || showWeek ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
@ -91,35 +98,12 @@ export function WizardDateField({
},
}}
>
{Boolean(allowWeekMode) && (
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
{(["week", "date"] as const).map((m) => {
const activeMode = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
style={{
height: 26,
padding: "0 12px",
borderRadius: 9999,
fontFamily: "var(--font-sans)",
fontSize: 11.5,
fontWeight: 500,
background: activeMode
? "var(--color-primary-tint)"
: "var(--color-bg-surface)",
border: `1px solid ${activeMode ? "var(--color-primary)" : "var(--color-border)"}`,
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
</div>
)}
<WizardDateModeTabs
modes={modes}
cellMode={cellMode}
allowRange={allowRange}
onSelect={setCellMode}
/>
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
@ -130,6 +114,16 @@ export function WizardDateField({
close();
}}
/>
) : allowRange && cellMode === "range" ? (
<WizardDateRangePanel
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeClick={handleRangeClick}
onDone={(start, end) => {
onChangeRange?.(start, end);
close();
}}
/>
) : (
<MiniCalendar
value={value}

View file

@ -0,0 +1,57 @@
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
type WizardDateModeTabsProps = {
modes: WizardSchedMode[];
cellMode: WizardSchedMode;
allowRange: boolean;
onSelect: (mode: WizardSchedMode) => void;
};
function modeLabel(mode: WizardSchedMode, allowRange: boolean): string {
if (mode === "week") {
return "Week";
}
if (mode === "range") {
return "Range";
}
return allowRange ? "Date" : "Specific date";
}
export function WizardDateModeTabs({
modes,
cellMode,
allowRange,
onSelect,
}: WizardDateModeTabsProps) {
if (modes.length === 0) {
return null;
}
return (
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
{modes.map((m) => {
const activeMode = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => onSelect(m)}
style={{
height: 26,
padding: "0 12px",
borderRadius: 9999,
fontFamily: "var(--font-sans)",
fontSize: 11.5,
fontWeight: 500,
background: activeMode ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
border: `1px solid ${activeMode ? "var(--color-primary)" : "var(--color-border)"}`,
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{modeLabel(m, allowRange)}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,65 @@
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { rangeDayCount } from "@/domain/work-orders/utils/wizard-date-utils";
type WizardDateRangePanelProps = {
rangeStart: string;
rangeEnd: string;
onRangeClick: (iso: string) => void;
onDone: (start: string, end: string) => void;
};
export function WizardDateRangePanel({
rangeStart,
rangeEnd,
onRangeClick,
onDone,
}: WizardDateRangePanelProps) {
const canConfirm = Boolean(rangeStart && rangeEnd);
return (
<>
<MiniCalendar
range
value={rangeStart}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onChange={onRangeClick}
/>
{Boolean(rangeStart) && (
<div
className="text-center"
style={{ marginTop: 4, fontSize: 11.5, color: "var(--color-text-muted)" }}
>
{(() => {
const n = rangeDayCount(rangeStart, rangeEnd || rangeStart);
return `${n} ${n === 1 ? "day" : "days"} selected`;
})()}
</div>
)}
<button
type="button"
disabled={!canConfirm}
onClick={() => {
if (canConfirm) {
onDone(rangeStart, rangeEnd);
}
}}
className="w-full transition-opacity"
style={{
marginTop: 4,
height: 32,
borderRadius: 6,
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
fontFamily: "var(--font-sans)",
fontSize: 12.5,
fontWeight: 500,
opacity: canConfirm ? 1 : 0.5,
pointerEvents: canConfirm ? "auto" : "none",
}}
>
Done
</button>
</>
);
}

View file

@ -0,0 +1,86 @@
import { Check } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
type WizardFieldSelectOptionsProps = {
search: boolean;
searchPlaceholder: string;
query: string;
onQueryChange: (q: string) => void;
options: WizardMenuOption[];
value: string;
onPick: (key: string) => void;
};
export function WizardFieldSelectOptions({
search,
searchPlaceholder,
query,
onQueryChange,
options,
value,
onPick,
}: WizardFieldSelectOptionsProps) {
return (
<>
{search && (
<div className="mb-1 px-1 pb-1.5" style={{ borderBottom: "1px solid var(--color-border)" }}>
<input
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
style={{
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
/>
</div>
)}
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{options.length === 0 ? (
<Text
as="p"
className="px-2 py-2 text-center"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
No matches
</Text>
) : (
options.map((o) => (
<button
key={o.key}
type="button"
onClick={() => onPick(o.key)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
<span className="min-w-0 flex-1 text-left">
<span
className="block truncate text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
</span>
{Boolean(o.description) && (
<span
className="mt-0.5 block truncate text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
{o.description}
</span>
)}
</span>
{o.right != null && <span className="flex shrink-0 items-center">{o.right}</span>}
{value === o.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}
</button>
))
)}
</div>
</>
);
}

View file

@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export interface WizardMenuOption {
key: string;
label: string;
description?: string;
left?: ReactNode;
right?: ReactNode;
}

View file

@ -1,15 +1,12 @@
import { useState, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react";
import { ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { Text } from "@/components/ui/text";
import { WizardFieldSelectOptions } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-options";
import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption {
key: string;
label: string;
left?: ReactNode;
}
export type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
type WizardFieldSelectProps = {
value: string;
@ -22,6 +19,19 @@ type WizardFieldSelectProps = {
disabled?: boolean;
};
function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] {
if (!query) {
return options;
}
const needle = query.toLowerCase();
return options.filter(
(o) =>
o.label.toLowerCase().includes(needle) ||
o.key.toLowerCase().includes(needle) ||
(o.description?.toLowerCase().includes(needle) ?? false),
);
}
export function WizardFieldSelect({
value,
placeholder,
@ -35,13 +45,7 @@ export function WizardFieldSelect({
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [q, setQ] = useState("");
const selected = options.find((o) => o.key === value);
const filtered = q
? options.filter(
(o) =>
o.label.toLowerCase().includes(q.toLowerCase()) ||
o.key.toLowerCase().includes(q.toLowerCase()),
)
: options;
const filtered = filterOptions(options, q);
const handleClose = () => {
close();
@ -74,9 +78,8 @@ export function WizardFieldSelect({
}}
aria-haspopup="listbox"
>
{icon != null && <span className="flex shrink-0 items-center">{icon}</span>}
{icon == null && selected?.left != null && (
<span className="flex shrink-0 items-center">{selected.left}</span>
{(selected?.left ?? icon) != null && (
<span className="flex shrink-0 items-center">{selected?.left ?? icon}</span>
)}
{selected ? (
<span
@ -110,59 +113,18 @@ export function WizardFieldSelect({
},
}}
>
{search && (
<div
className="mb-1 px-1 pb-1.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
style={{
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
/>
</div>
)}
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{filtered.length === 0 ? (
<Text
as="p"
className="px-2 py-2 text-center"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
No matches
</Text>
) : (
filtered.map((o) => (
<button
key={o.key}
type="button"
onClick={() => {
onPick(o.key);
<WizardFieldSelectOptions
search={search}
searchPlaceholder={searchPlaceholder}
query={q}
onQueryChange={setQ}
options={filtered}
value={value}
onPick={(key) => {
onPick(key);
handleClose();
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
<span
className="flex-1 truncate text-left text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
</span>
{value === o.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}
</button>
))
)}
</div>
/>
</Popover>
</>
);

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

@ -0,0 +1,45 @@
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import {
fmtDate,
fmtDateRange,
fmtWeekRange,
formatApptRange,
} from "@/domain/work-orders/utils/wizard-date-utils";
function scheduleOnLabel(draft: WorkOrderWizardDraft): string {
if (draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd) {
return fmtDateRange(draft.scheduledOn, draft.scheduleEnd);
}
if (!draft.scheduledOn) {
return "—";
}
if (draft.scheduleWeekOnly) {
return fmtWeekRange(draft.targetWeek || draft.scheduledOn);
}
return fmtDate(draft.scheduledOn);
}
function pocLabel(draft: WorkOrderWizardDraft): string {
if (!draft.pocName) {
return "—";
}
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
}
/** Builds the label/value rows for the wizard sidebar's live summary card. */
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
return [
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
["Type", draft.type || "—"],
["Schedule on", scheduleOnLabel(draft)],
["Due date", fmtDate(draft.dueDate) || "—"],
["Assigned to", draft.assigneeName || "—"],
["Site", draft.siteCode || draft.locationName || "—"],
["POC", pocLabel(draft)],
["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"],
["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"],
["Status", draft.status || "Incomplete"],
];
}

View file

@ -0,0 +1,82 @@
import {
WIZARD_STATUS_DOTS,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { buildWizardSidebarSummaryRows } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows";
import { ColorDot } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
export function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
const summary = buildWizardSidebarSummaryRows(draft);
const statusLabel = draft.status || "Incomplete";
const statusValue = statusLabel as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
return (
<div style={{ marginTop: "auto", paddingTop: 44 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
marginBottom: 8,
}}
>
This work order
</div>
{summary.map(([label, val], i) => (
<div
key={label}
className="flex items-center justify-between gap-2"
style={{
padding: "4px 0",
borderBottom: i < summary.length - 1 ? "1px solid var(--color-border)" : "none",
}}
>
<span
className="shrink-0"
style={{
fontFamily: "var(--font-sans)",
fontSize: 11,
fontWeight: 400,
color: "var(--muted-foreground)",
}}
>
{label}
</span>
{label === "Status" ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<ColorDot color={statusDotColor} />
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{statusLabel}
</span>
</span>
) : (
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{val}
</span>
)}
</div>
))}
</div>
);
}

View file

@ -1,19 +1,10 @@
import { ClipboardList } from "lucide-react";
import {
WIZARD_STATUS_DOTS,
WIZARD_STEPS,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import {
fmtDate,
fmtWeekRange,
formatApptRange,
} from "@/domain/work-orders/utils/wizard-date-utils";
import {
ColorDot,
StepCircle,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { StepCircle } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardSidebarSummary } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary";
type WizardSidebarProps = {
step: number;
@ -120,106 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
</div>
);
}
function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
const summary: [string, string][] = [
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
["Type", draft.type || "—"],
[
"Schedule on",
draft.scheduledOn
? draft.scheduleWeekOnly
? fmtWeekRange(draft.scheduledOn)
: fmtDate(draft.scheduledOn)
: "—",
],
["Due date", fmtDate(draft.dueDate) || "—"],
["Assigned to", draft.assigneeName || "—"],
["Site", draft.locationName || "—"],
[
"POC",
draft.pocName
? draft.pocPhone
? `${draft.pocName} · ${draft.pocPhone}`
: draft.pocName
: "—",
],
["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"],
["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"],
["Status", draft.status || "Incomplete"],
];
const statusLabel = draft.status || "Incomplete";
const statusValue = statusLabel as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
return (
<div style={{ marginTop: "auto", paddingTop: 44 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
marginBottom: 8,
}}
>
This work order
</div>
{summary.map(([label, val], i) => (
<div
key={label}
className="flex items-center justify-between gap-2"
style={{
padding: "4px 0",
borderBottom: i < summary.length - 1 ? "1px solid var(--color-border)" : "none",
}}
>
<span
className="shrink-0"
style={{
fontFamily: "var(--font-sans)",
fontSize: 11,
fontWeight: 400,
color: "var(--muted-foreground)",
}}
>
{label}
</span>
{label === "Status" ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<ColorDot color={statusDotColor} />
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{statusLabel}
</span>
</span>
) : (
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{val}
</span>
)}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,42 @@
import { Text } from "@/components/ui/text";
import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
export function WizardStepHeader({ step }: { step: number }) {
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--color-primary)",
}}
>
Step {step} of {WIZARD_STEPS.length}
</div>
<Text
variant="title"
sx={{
fontWeight: 700,
fontSize: 20,
color: "var(--foreground)",
marginTop: "6px",
}}
>
{WIZARD_STEPS[step - 1].title}
</Text>
<Text
variant="description"
sx={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: "4px",
}}
>
{WIZARD_STEPS[step - 1].subtitle}
</Text>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
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,
showErrors = false,
onUpdate,
}: WizardStepLocationPocFieldsProps) {
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return (
<div>
<WizLabel required>Point of contact</WizLabel>
<Text
as="p"
variant="description"
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
>
Auto-filled from records — edit if outdated
</Text>
<div className="grid grid-cols-2 gap-2">
<input
value={draft.pocName}
onChange={(e) => onUpdate("pocName", e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={draft.pocPhone}
onChange={(e) => onUpdate("pocPhone", e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
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)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
);
}

View file

@ -0,0 +1,99 @@
import { Droplets, Plus } from "lucide-react";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type ServiceOption = { id: string | number; value: string };
type WizardStepLocationServiceSelectProps = {
draft: WorkOrderWizardDraft;
services: ServiceOption[];
svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void;
svcSearch: string;
onSvcSearchChange: (q: string) => void;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
export function WizardStepLocationServiceSelect({
draft,
services,
svcOpen,
onSvcOpenChange,
svcSearch,
onSvcSearchChange,
onUpdate,
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
const selectSvc = (p: string) => {
onPatch({
pm: p,
extraServices: [],
...clearVendorIfPrimaryChanged(p),
});
};
return (
<div>
<WizLabel optional>Service</WizLabel>
<WizardFieldSelect
value={draft.pm}
placeholder="Select service…"
icon={
<Droplets size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search PM type…"
options={services.map((s) => {
const Icon = getPmTypeIcon(s.value);
return {
key: s.value,
label: s.value,
left: (
<Icon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
),
};
})}
onPick={selectSvc}
/>
{!svcOpen ? (
<button
type="button"
onClick={() => onSvcOpenChange(true)}
className="transition-colors"
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--color-primary)",
padding: "6px 0 0",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<Plus size={12} /> Service &amp; Notes
</button>
) : (
<div
className="mt-2 rounded-lg p-3"
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
>
<WizardServiceNotesFields
services={services}
sel={draft.pm ? [draft.pm] : []}
onToggle={selectSvc}
note={draft.pmNote}
setNote={(v) => onUpdate("pmNote", v)}
search={svcSearch}
setSearch={onSvcSearchChange}
/>
</div>
)}
</div>
);
}

View file

@ -1,18 +1,11 @@
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
Detail,
WizLabel,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
import { WizardStepLocationSiteSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select";
type ServiceOption = { id: string | number; value: string };
@ -20,151 +13,66 @@ type WizardStepLocationServiceProps = {
draft: WorkOrderWizardDraft;
locations: LocationOption[];
locationDetail: Location | undefined;
locationDetailLoading?: boolean;
locationDetailError?: boolean;
services: ServiceOption[];
showErrors?: boolean;
svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void;
svcSearch: string;
onSvcSearchChange: (q: string) => void;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
function buildMapsUrl(location: Location): string {
const parts = [location.address, location.city, location.state, location.zipCode]
.filter(Boolean)
.join(", ");
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
}
export function WizardStepLocationService({
draft,
locations,
locationDetail,
locationDetailLoading,
locationDetailError,
services,
showErrors = false,
svcOpen,
onSvcOpenChange,
svcSearch,
onSvcSearchChange,
onUpdate,
onPatch,
}: WizardStepLocationServiceProps) {
const selectedLoc = locations.find((l) => String(l.id) === draft.locationId);
return (
<div className="space-y-5">
<div>
<WizLabel required>Site</WizLabel>
<WizardFieldSelect
value={draft.locationId}
placeholder="Select site…"
icon={
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search site code or city…"
options={locations.map((loc) => ({
key: String(loc.id),
label: loc.name,
}))}
onPick={(id) => {
const loc = locations.find((l) => String(l.id) === id);
onPatch({
locationId: id,
locationName: loc?.name ?? "",
});
}}
<WizardStepLocationSiteSelect
locationId={draft.locationId}
locations={locations}
onPatch={onPatch}
/>
{!draft.locationId && (
<Text
as="p"
variant="description"
className="mt-1.5 flex items-center gap-1"
sx={{ fontSize: 11, color: "var(--muted-foreground)" }}
>
<Info size={12} className="shrink-0" /> Site is required to continue.
</Text>
)}
</div>
{locationDetail != null && draft.locationId !== "" && (
{draft.locationId !== "" && (
<>
<div
className="space-y-1.5 rounded-lg p-3"
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
>
<Detail
icon={MapPin}
text={
[
locationDetail.address,
locationDetail.city,
locationDetail.state,
locationDetail.zipCode,
]
.filter(Boolean)
.join(", ") || "—"
}
<WizardStepLocationSiteDetails
selectedLoc={selectedLoc}
locationName={draft.locationName}
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
/>
{Boolean(locationDetail.phone) && (
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
{Boolean(locationDetail.address) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
</div>
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<Text
as="p"
variant="description"
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
>
Auto-filled from records — edit if outdated
</Text>
<div className="grid grid-cols-2 gap-2">
<input
value={draft.pocName}
onChange={(e) => onUpdate("pocName", e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={draft.pocPhone}
onChange={(e) => onUpdate("pocPhone", e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<textarea
value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
<WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
</>
)}
<div>
<WizLabel optional>Service</WizLabel>
<WizardFieldSelect
value={draft.pm}
placeholder="Select service…"
icon={
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search PM type…"
options={services.map((s) => ({
key: s.value,
label: s.value,
}))}
onPick={(p) => onUpdate("pm", p)}
<WizardStepLocationServiceSelect
draft={draft}
services={services}
svcOpen={svcOpen}
onSvcOpenChange={onSvcOpenChange}
svcSearch={svcSearch}
onSvcSearchChange={onSvcSearchChange}
onUpdate={onUpdate}
onPatch={onPatch}
/>
</div>
{!draft.locationId && !draft.pm && (
<Text

View file

@ -0,0 +1,88 @@
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
type WizardStepLocationSiteDetailsProps = {
selectedLoc: LocationOption | undefined;
locationName: string;
locationDetail: Location | undefined;
locationDetailLoading?: boolean;
locationDetailError?: boolean;
};
function SiteDetailsSkeleton() {
return (
<div className="space-y-1.5" aria-busy="true" role="status" aria-label="Loading site details">
<span
className="block h-3 w-3/4 animate-pulse rounded"
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
/>
<span
className="block h-3 w-1/2 animate-pulse rounded"
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
/>
</div>
);
}
export function WizardStepLocationSiteDetails({
selectedLoc,
locationName,
locationDetail,
locationDetailLoading,
locationDetailError,
}: WizardStepLocationSiteDetailsProps) {
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
return (
<div
className="space-y-1.5 rounded-lg p-3"
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
>
{locationDetail ? (
<>
<Detail icon={MapPin} text={addressPreview || selectedLoc?.name || locationName || "—"} />
{Boolean(locationDetail.phone) && (
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
</>
) : (
<>
<Detail icon={MapPin} text={selectedLoc?.name || locationName || "—"} />
{Boolean(locationDetailLoading) && <SiteDetailsSkeleton />}
{Boolean(locationDetailError) && (
<Text
as="p"
variant="description"
sx={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
Could not load full site details. City and site code above are still available.
</Text>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Info, MapPin } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationSiteSelectProps = {
locationId: string;
locations: LocationOption[];
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
export function WizardStepLocationSiteSelect({
locationId,
locations,
onPatch,
}: WizardStepLocationSiteSelectProps) {
return (
<div>
<WizLabel required>Site</WizLabel>
<WizardFieldSelect
value={locationId}
placeholder="Select site…"
icon={
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search site code or city…"
options={locations.map((loc) => ({
key: String(loc.id),
label: loc.name,
}))}
onPick={(id) => {
const loc = locations.find((l) => String(l.id) === id);
// Switching site invalidates the previous POC — clear so autofill can
// seed from the new location without clobbering mid-load edits later.
onPatch({
locationId: id,
locationName: loc?.name ?? "",
siteCode: loc ? resolveSiteCode(loc) : "",
pocName: "",
pocPhone: "",
pocNotes: "",
});
}}
/>
{!locationId && (
<Text
as="p"
variant="description"
className="mt-1.5 flex items-center gap-1"
sx={{ fontSize: 11, color: "var(--muted-foreground)" }}
>
<Info size={12} className="shrink-0" /> Site is required to continue.
</Text>
)}
</div>
);
}

View file

@ -1,7 +1,17 @@
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";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
@ -14,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, Plus, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react";
@ -25,6 +36,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,73 +134,28 @@ function WoTypePicker({
);
}
type WizardStepTypeScheduleProps = {
type WizardTypeScheduleDatesProps = {
draft: WorkOrderWizardDraft;
users: WorkOrderUser[];
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void;
};
export function WizardStepTypeSchedule({
draft,
users,
onUpdate,
onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
return (
<div className="space-y-6">
<div>
<WizLabel required>Type of WO</WizLabel>
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
</div>
<div>
<WizLabel optional>WO Number</WizLabel>
<input
type="text"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value)}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 24818"
maxLength={20}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizLabel optional>Assigned To</WizLabel>
<WizardFieldSelect
value={draft.assigneeId}
placeholder="Select…"
icon={
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search dispatcher…"
options={users.map((u) => ({
key: String(u.id),
label: u.name,
left: <ColorDot color="var(--color-primary)" />,
}))}
onPick={(id) => {
const user = users.find((u) => String(u.id) === id);
onPatch({
assigneeId: id,
assigneeName: user?.name ?? "",
});
}}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly
? () => onPatch({ scheduledOn: "", scheduleWeekOnly: false })
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
@ -186,11 +163,38 @@ export function WizardStepTypeSchedule({
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
})
}
/>
</div>
@ -209,6 +213,103 @@ export function WizardStepTypeSchedule({
/>
</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;
};
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 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"
value={draft.woNumber}
onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
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>
<WizLabel optional>Assigned To</WizLabel>
<WizardFieldSelect
value={draft.assigneeId}
placeholder="Select…"
icon={
selectedUser ? (
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
) : (
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search dispatcher…"
options={users.map((u) => ({
key: String(u.id),
label: u.name,
left: <ColorDot color={getDispatcherAvatarColor(u)} />,
}))}
onPick={(id) => {
const user = users.find((u) => String(u.id) === id);
onPatch({
assigneeId: id,
assigneeName: user?.name ?? "",
});
}}
/>
</div>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
</div>
);
}

View file

@ -1,21 +1,21 @@
import { Clock, ExternalLink, Mail, Wrench } from "lucide-react";
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
ALL_WIZARD_STATUSES,
TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
@ -33,11 +33,13 @@ function WizardVendorDetail({
vendor,
techPhone,
vendorNotes,
distance,
onUpdate,
}: {
vendor: VendorDropdownItem;
techPhone: string;
vendorNotes: string;
distance: string;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
}) {
return (
@ -61,9 +63,20 @@ function WizardVendorDetail({
border: "1px solid var(--color-border)",
}}
>
<div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "—"}
</div>
{Boolean(distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
{distance}
</span>
)}
</div>
{Boolean(vendor.contactName) && <Detail icon={User} text={vendor.contactName} />}
{Boolean(vendor.tradeSpecialties) && (
<Detail icon={Wrench} text={vendor.tradeSpecialties} />
)}
{Boolean(vendor.address) && <Detail icon={Mail} text={vendor.address} />}
{Boolean(vendor.address) && (
<a
@ -100,8 +113,7 @@ export function WizardStepVendorTime({
onPatch,
}: WizardStepVendorTimeProps) {
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
return (
<div className="space-y-5">
@ -133,7 +145,9 @@ export function WizardStepVendorTime({
color: "var(--color-text-secondary)",
}}
>
{selectedVendor.companyName.slice(0, 2).toUpperCase()}
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
@ -142,7 +156,7 @@ export function WizardStepVendorTime({
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: v.companyName,
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
@ -160,6 +174,7 @@ export function WizardStepVendorTime({
vendor={selectedVendor}
techPhone={draft.techPhone}
vendorNotes={draft.vendorNotes}
distance={selectedDistance}
onUpdate={onUpdate}
/>
)}
@ -213,22 +228,6 @@ export function WizardStepVendorTime({
</Text>
)}
</div>
<div>
<WizLabel>Status</WizLabel>
<WizardFieldSelect
value={statusValue}
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={ALL_WIZARD_STATUSES.map((s) => ({
key: s,
label: s,
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
}))}
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
/>
</div>
</div>
);
}

View file

@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
import { Box, Breadcrumbs, Button, Link, Paper, Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
@ -28,6 +29,7 @@ import {
WorkOrderDetailsFields,
} from "@/app/(protected)/workorders/_components/work-order-fields";
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
type ScheduleBounds = {
kind: "valid" | "invalid" | "open";
@ -125,7 +127,12 @@ function useWorkOrderFormState() {
const isEdit = Boolean(id);
const assignVendorRequested = searchParams.get("assignVendor") === "1";
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
const {
data: detail,
isLoading: isLoadingDetail,
error: detailError,
refetch: refetchDetail,
} = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
@ -225,6 +232,7 @@ function useWorkOrderFormState() {
detail,
isLoading,
detailError,
refetchDetail,
isSaving: saveMutation.isPending,
register,
control,
@ -264,6 +272,7 @@ export default function WorkOrderFormPage() {
detail,
isLoading,
detailError,
refetchDetail,
isSaving,
register,
control,
@ -296,17 +305,16 @@ export default function WorkOrderFormPage() {
} = useWorkOrderFormState();
if (isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
return <DetailPageSkeleton variant="form" />;
}
if (detailError) {
return (
<Box className="p-4">
<Text variant="error">Unable to load this work order. Please try again.</Text>
<QueryErrorPanel
message={detailError instanceof Error ? detailError.message : "Failed to load work order"}
onRetry={() => void refetchDetail()}
/>
</Box>
);
}

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,11 +45,34 @@ 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(
detail: WorkOrderBoardDetail | undefined,
@ -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);
setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
}
return prev.filter((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

@ -2,7 +2,11 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */
/**
* Mutates `payload` with `dueDate` and schedule fields.
* Range mode lands on the board using the range start as `scheduledDate`
* (prototype `hasScheduleDate`: a range start qualifies as scheduled).
*/
export function assignWizardScheduleFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,
@ -21,7 +25,7 @@ export function assignWizardScheduleFields(
payload.scheduleWeekOnly = true;
payload.targetWeek = week;
}
} else if (!isRange && draft.scheduledOn) {
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {
payload.scheduledDate = draft.scheduledOn;
}
}

View file

@ -7,42 +7,7 @@ function trimOrUndefined(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
function normalizeExtraServices(
primary: string | undefined,
extras: string[],
): string[] | undefined {
if (extras.length === 0) {
return undefined;
}
const seen = new Set<string>();
const primaryKey = primary?.toLowerCase() ?? "";
const out: string[] = [];
for (const raw of extras) {
const trimmed = raw.trim();
if (!trimmed) {
continue;
}
const key = trimmed.toLowerCase();
if (key === primaryKey || seen.has(key)) {
continue;
}
seen.add(key);
out.push(trimmed);
}
return out.length > 0 ? out : undefined;
}
/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */
/** Mutates `payload` with primary service, service notes, POC, tech phone, and vendor notes. */
export function assignWizardServicePocFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,
@ -53,12 +18,6 @@ export function assignWizardServicePocFields(
payload.primaryService = primaryService;
}
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
if (extraServices) {
payload.extraServices = extraServices;
}
const serviceNotes = trimOrUndefined(draft.pmNote);
if (serviceNotes) {
@ -94,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";
@ -66,13 +67,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 {
@ -103,7 +105,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,
@ -118,6 +120,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";
@ -80,7 +87,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 });
@ -98,6 +105,10 @@ export function mapWizardDraftToBoardCreatePayload(
siteCode,
};
if (draft.status) {
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
}
if (draft.woNumber.trim()) {
payload.woNumber = draft.woNumber.trim();
}

View file

@ -8,6 +8,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";
@ -234,6 +235,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;
@ -91,6 +93,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). */
@ -120,7 +124,8 @@ export type WorkOrderBoardEditableField =
| "pm"
| "serviceNotes"
| "extraServices"
| "flagColor";
| "flagColor"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
export interface WorkOrderBoardRowApi {
@ -145,6 +150,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

@ -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;
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
site: string;
woNumber: string;
type: WOType | "";
severity: WorkOrderSeverity | null;
dispatcherId: string;
scheduledOn: string;
schedNotes: string;
@ -84,6 +88,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";
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
export type WOType = (typeof WO_TYPES)[number];
@ -24,6 +28,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;
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
siteCode: "",
woNumber: "",
type: "",
severity: null,
assigneeId: "",
assigneeName: "",
scheduledOn: "",
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
schedMode: undefined,
};
/** 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 }[] = [
@ -159,11 +175,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 {
@ -171,7 +198,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,

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