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", "@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0", "@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/query-broadcast-client-experimental": "5.101.2",
"@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@ -2332,6 +2333,20 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/query-broadcast-client-experimental": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz",
"integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.2",
"broadcast-channel": "^7.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.101.2", "version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
@ -3214,6 +3229,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/broadcast-channel": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz",
"integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.28.6",
"oblivious-set": "2.0.0",
"p-queue": "6.6.2",
"unload": "2.4.1"
},
"funding": {
"url": "https://github.com/sponsors/pubkey"
}
},
"node_modules/broadcast-channel/node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.6", "version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@ -5345,6 +5384,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oblivious-set": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz",
"integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
@ -5393,6 +5441,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -5425,6 +5482,40 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6401,6 +6492,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unload": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
"integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/pubkey"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View file

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

View file

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

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

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

View file

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

View file

@ -28,6 +28,7 @@ export function CompDocDialogFooterDraftActions({
className="gap-1.5 whitespace-nowrap text-[12px]" className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX} sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }} style={{ color: "var(--muted-foreground)" }}
disabled={isUploading}
onClick={onMarkNotRequired} onClick={onMarkNotRequired}
> >
<Ban size={14} /> Mark as not required <Ban size={14} /> Mark as not required
@ -38,6 +39,7 @@ export function CompDocDialogFooterDraftActions({
size="default" size="default"
className="gap-1.5 whitespace-nowrap text-[12px]" className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX} sx={COMP_DOC_FOOTER_BTN_SX}
disabled={isUploading}
onClick={onSaveDraft} onClick={onSaveDraft}
> >
<Save size={14} /> Save draft <Save size={14} /> Save draft
@ -47,7 +49,7 @@ export function CompDocDialogFooterDraftActions({
size="default" size="default"
className="gap-1.5 whitespace-nowrap text-[12px]" className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX} sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion} disabled={!rowVersion || isUploading}
loading={isUploading} loading={isUploading}
loadingText="Uploading…" loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()} onClick={() => pdfInputRef.current?.click()}
@ -60,6 +62,7 @@ export function CompDocDialogFooterDraftActions({
className="gap-1.5 whitespace-nowrap text-[12px]" className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX} sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }} style={{ background: "var(--color-primary)", color: "#fff" }}
disabled={isUploading}
onClick={onGenerate} onClick={onGenerate}
> >
<Sparkles size={14} /> Generate <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"; import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = { type CompDocDialogHeaderProps = {
info: WorkOrderTableRow; row: WorkOrderTableRow;
cfg: CompletionDocStateConfig; cfg: CompletionDocStateConfig;
isUploading: boolean; isUploading: boolean;
onClose: () => void; onClose: () => void;
}; };
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
return ( return (
<div <div
className="flex shrink-0 items-center justify-between" className="flex shrink-0 items-center justify-between"
@ -45,10 +45,10 @@ export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDoc
> >
WO{" "} WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}> <span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{info.woNumber} #{row.woNumber}
</span> </span>
{" · "} {" · "}
{serviceTitle(info)} {serviceTitle(row)}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "react-toastify";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
@ -56,8 +57,6 @@ export function useSlideOverMediaState({
deleteMediaMutation.isPending || deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending; categorizeMediaMutation.isPending;
const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
const uploadMediaRequests = (requests: MediaUploadRequest[]) => { const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
startUploads(requests); startUploads(requests);
}; };
@ -74,7 +73,11 @@ export function useSlideOverMediaState({
}; };
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => { 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 }); categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
}; };
@ -83,7 +86,11 @@ export function useSlideOverMediaState({
removeLocal(id); removeLocal(id);
return; 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 }); 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[]; items: DispatchChecklistItem[];
isLocked: boolean; isLocked: boolean;
isUpdating: boolean; isUpdating: boolean;
isAdding?: boolean;
newItem: string; newItem: string;
onNewItemChange: (value: string) => void; onNewItemChange: (value: string) => void;
onToggleItem: (itemId: string | number, isCompleted: boolean) => void; onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
@ -16,6 +17,7 @@ export function DispatchChecklistSection({
items, items,
isLocked, isLocked,
isUpdating, isUpdating,
isAdding = false,
newItem, newItem,
onNewItemChange, onNewItemChange,
onToggleItem, onToggleItem,
@ -58,14 +60,16 @@ export function DispatchChecklistSection({
value={newItem} value={newItem}
onChange={(event) => onNewItemChange(event.target.value)} onChange={(event) => onNewItemChange(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && newItem.trim()) { if (event.key === "Enter" && newItem.trim() && !isAdding) {
event.preventDefault(); event.preventDefault();
onAddItem(newItem.trim()); onAddItem(newItem.trim());
} }
}} }}
placeholder="Add checklist item..." placeholder={isAdding ? "Adding…" : "Add checklist item..."}
disabled={isAdding}
fullWidth fullWidth
className="mt-2" className="mt-2"
slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }}
/> />
)} )}
</Box> </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 { DetailField } from "@/components/common/detail-field";
import { Button } from "@/components/ui/button";
import { import {
DISPATCH_STATUS_OPTIONS, DISPATCH_STATUS_OPTIONS,
type DispatchDetail, type DispatchDetail,
@ -84,10 +85,12 @@ export function DispatchEditForm({
onChange={(event) => onChange({ ...form, description: event.target.value })} onChange={(event) => onChange({ ...form, description: event.target.value })}
/> />
<Stack direction="row" spacing={1} className="sm:col-span-2"> <Stack direction="row" spacing={1} className="sm:col-span-2">
<Button variant="contained" onClick={onSave} disabled={isSaving}> <Button onClick={onSave} loading={isSaving} loadingText="Saving…">
{isSaving ? "Saving..." : "Save"} Save
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button> </Button>
<Button onClick={onCancel}>Cancel</Button>
</Stack> </Stack>
</> </>
); );
@ -138,12 +141,10 @@ export function DispatchDetailsView({
)} )}
{!isLocked && ( {!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2"> <Stack direction="row" spacing={1} className="sm:col-span-2">
<Button color="error" variant="outlined" onClick={onCancelDispatch}> <Button variant="destructive" onClick={onCancelDispatch}>
Cancel Dispatch Cancel Dispatch
</Button> </Button>
<Button variant="contained" onClick={onEdit}> <Button onClick={onEdit}>Edit</Button>
Edit
</Button>
</Stack> </Stack>
)} )}
</> </>

View file

@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils";
type DispatchSignoffsSectionProps = { type DispatchSignoffsSectionProps = {
signoffs: DispatchSignoff[]; signoffs: DispatchSignoff[];
isLocked: boolean; isLocked: boolean;
isSigningOff?: boolean;
onCollectSignature: (type: "Customer" | "Vendor") => void; onCollectSignature: (type: "Customer" | "Vendor") => void;
}; };
export function DispatchSignoffsSection({ export function DispatchSignoffsSection({
signoffs, signoffs,
isLocked, isLocked,
isSigningOff = false,
onCollectSignature, onCollectSignature,
}: DispatchSignoffsSectionProps) { }: DispatchSignoffsSectionProps) {
return ( return (
@ -49,9 +51,9 @@ export function DispatchSignoffsSection({
variant="outlined" variant="outlined"
className="mt-2" className="mt-2"
onClick={() => onCollectSignature(type)} onClick={() => onCollectSignature(type)}
disabled={isLocked} disabled={isLocked || isSigningOff}
> >
{isLocked ? "Not signed" : "Collect Signature"} {isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"}
</Button> </Button>
)} )}
</Box> </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 { CommentTimeline } from "@/components/common/comment-timeline";
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import type { DispatchComment } from "@/domain/work-orders/types/work-order"; import type { DispatchComment } from "@/domain/work-orders/types/work-order";
type DispatchVendorCommunicationProps = { type DispatchVendorCommunicationProps = {
@ -61,10 +62,11 @@ export function DispatchVendorCommunication({
/> />
<Button <Button
type="submit" type="submit"
variant="contained" disabled={isLocked || !commentText.trim()}
disabled={isLocked || isPending || !commentText.trim()} loading={isPending}
loadingText="Sending…"
> >
{isPending ? "Sending..." : "Send"} Send
</Button> </Button>
</Stack> </Stack>
</Box> </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"; import { formatDateTime } from "@/lib/time-utils";
type DispatchVerifyFooterProps = { type DispatchVerifyFooterProps = {
@ -34,10 +35,15 @@ export function DispatchVerifyFooter({
</Typography> </Typography>
)} )}
<Button <Button
variant="contained" disabled={!canVerify}
color="success" loading={isPending}
disabled={!canVerify || isPending} loadingText="Verifying…"
onClick={onVerify} onClick={onVerify}
sx={{
backgroundColor: "success.main",
color: "#fff",
"&:hover": { backgroundColor: "success.dark" },
}}
> >
Verify & Close Verify & Close
</Button> </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 { SignatureCapture } from "@/components/common/signature-capture";
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller"; 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 { 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"; import { UpliftDecisionDialog } from "./uplift-decision-dialog";
interface DispatchDetailModalProps { interface DispatchDetailModalProps {
@ -23,13 +20,16 @@ export function DispatchDetailModal({
onClose, onClose,
}: DispatchDetailModalProps) { }: DispatchDetailModalProps) {
const controller = useDispatchDetailController(dispatchId, workOrderId); 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 ( return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth> <Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12"> <DispatchDetailSkeleton />
<CircularProgress />
</DialogContent>
</Dialog> </Dialog>
); );
} }
@ -42,94 +42,23 @@ export function DispatchDetailModal({
return ( return (
<> <>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper"> <Dialog open onClose={handleClose} maxWidth="md" fullWidth scroll="paper">
<DialogTitle> <DialogTitle>
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} /> <DispatchDetailHeader
dispatch={dispatch}
onClose={() => {
if (!controller.isMutating) onClose();
}}
/>
</DialogTitle> </DialogTitle>
<DialogContent className="flex flex-col gap-6"> <DispatchDetailBody dispatch={dispatch} controller={controller} />
<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>
</Dialog> </Dialog>
{controller.sigCaptureType != null && ( {controller.sigCaptureType != null && (
<SignatureCapture <SignatureCapture
title={`${controller.sigCaptureType} Sign-off`} title={`${controller.sigCaptureType} Sign-off`}
isPending={controller.addSignoffMutation.isPending}
onClose={() => controller.setSigCaptureType(null)} onClose={() => controller.setSigCaptureType(null)}
onSave={(signature, method, name) => { onSave={(signature, method, name) => {
controller.addSignoffMutation.mutate( controller.addSignoffMutation.mutate(
@ -151,13 +80,13 @@ export function DispatchDetailModal({
type={controller.upliftAction.type} type={controller.upliftAction.type}
requestedNTE={controller.upliftAction.uplift.requestedNTE} requestedNTE={controller.upliftAction.uplift.requestedNTE}
currentNTE={controller.upliftAction.uplift.currentNTE} currentNTE={controller.upliftAction.uplift.currentNTE}
onClose={() => controller.setUpliftAction(null)} onClose={() => {
if (!isDecidingUplift) {
controller.setUpliftAction(null);
}
}}
onConfirm={controller.handleUpliftConfirm} onConfirm={controller.handleUpliftConfirm}
isPending={ isPending={isDecidingUplift}
controller.approveUpliftMutation.isPending ||
controller.rejectUpliftMutation.isPending ||
controller.requestChangesUpliftMutation.isPending
}
/> />
)} )}
</> </>

View file

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

View file

@ -21,6 +21,7 @@ export function buildInlineDraftCreateInput(
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber, woNumber: draft.woNumber,
type: draft.type || "PM", type: draft.type || "PM",
severity: draft.severity,
assigneeId: draft.dispatcherId, assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "", assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn, 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 { useState } from "react";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { Popover } from "@mui/material"; 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 { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { HoverChevron } from "./hover-chevron"; import { HoverChevron } from "./hover-chevron";
import { SeverityBadge } from "./severity-badge";
import { TypeSeverityModal } from "./type-severity-modal";
import { WoTableTypeLabel } from "./wo-table-type-label"; import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = { type TypeCellProps = {
value: WOType | ""; value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string; q?: string;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
onSave: (v: WOType) => 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 [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 ( return (
<> <>
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setAnchorEl(e.currentTarget); setAnchorEl(e.currentTarget);
}} }}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" 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> <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 /> <HoverChevron />
</button> </button>
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => { onClick={() => pick(t)}
onSave(t);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]" className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
> >
<WoTableTypeLabel type={t} /> <WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />} {value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button> </button>
))} ))}
</Popover> </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; 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() { function InlineMutedDashCell() {
return ( return (
<td style={{ padding: "0 14px" }}> <td style={{ padding: "0 14px" }}>
@ -81,9 +99,7 @@ export function WoTableInlineRowCells({
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
/> />
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell <AssignedToCell

View file

@ -83,7 +83,12 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()} 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>
<td <td
className="group/cell" className="group/cell"

View file

@ -1,4 +1,4 @@
import { useRef } from "react"; import { useRef } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -20,7 +20,7 @@ export type MediaUploadRequest = {
type MediaUploaderProps = { type MediaUploaderProps = {
uploads: MediaFile[]; uploads: MediaFile[];
/** Upload-then-categorize: files only ÔÇö category assigned after persist. */ /** Upload-then-categorize: files only — category assigned after persist. */
onUpload?: (requests: MediaUploadRequest[]) => void; onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => 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 { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-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"; import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"]; 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 = { type MiniCalendarProps = {
value: string; value: string;
onChange: (iso: string) => void; onChange: (iso: string) => void;
/** Range-selection mode — parent owns the click logic. */
range?: boolean;
rangeStart?: string;
rangeEnd?: string;
}; };
export function MiniCalendar({ value, onChange }: MiniCalendarProps) { export function MiniCalendar({
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso(); 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 [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy); const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im); const [viewM, setViewM] = useState(im);
@ -81,8 +93,16 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
return <span key={i} />; return <span key={i} />;
} }
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
const selected = iso === value;
const isToday = iso === today; const isToday = iso === today;
const { bg, fg, radius, filled } = getMiniCalendarCellStyle({
iso,
value,
isToday,
range,
rangeStart,
rangeEnd,
});
return ( return (
<button <button
key={i} 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)]" className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
style={{ style={{
height: 26, height: 26,
borderRadius: 4, borderRadius: radius,
fontWeight: selected || isToday ? 600 : 400, fontWeight: filled || isToday ? 600 : 400,
background: selected ? "var(--color-primary)" : "transparent", background: filled ? bg : undefined,
color: selected color: fg,
? "var(--color-primary-foreground)"
: isToday
? "var(--color-primary)"
: "var(--color-text-primary)",
border: border:
isToday && !selected isToday && !filled
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)" ? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
: undefined, : undefined,
}} }}

View file

@ -1,16 +1,18 @@
import { Dialog } from "@mui/material"; import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; 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 { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { 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 { 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 { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar"; 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 { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; 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 { 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 = { type NewWoWizardProps = {
open: boolean; open: boolean;
@ -20,46 +22,6 @@ type NewWoWizardProps = {
onViewExistingWo?: (row: WorkOrderTableRow) => void; 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({ export function NewWoWizard({
open, open,
onOpenChange, onOpenChange,
@ -67,13 +29,13 @@ export function NewWoWizard({
existingRows = [], existingRows = [],
onViewExistingWo, onViewExistingWo,
}: NewWoWizardProps) { }: NewWoWizardProps) {
const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange }); const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
return ( return (
<> <>
<Dialog <Dialog
open={open} open={open}
onClose={() => onOpenChange(false)} onClose={wizard.handleClose}
maxWidth={false} maxWidth={false}
slotProps={{ slotProps={{
...dialogBlurBackdropSlotProps, ...dialogBlurBackdropSlotProps,
@ -85,13 +47,15 @@ export function NewWoWizard({
maxWidth: 768, maxWidth: 768,
height: 680, height: 680,
maxHeight: 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> <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} /> <WizardSidebar step={wizard.step} draft={wizard.draft} />
<div <div
@ -103,7 +67,8 @@ export function NewWoWizard({
{wizard.step === 1 && ( {wizard.step === 1 && (
<WizardStepTypeSchedule <WizardStepTypeSchedule
draft={wizard.draft} draft={wizard.draft}
users={wizard.wizardUsers} users={users}
showErrors={wizard.showErrors}
onUpdate={wizard.setField} onUpdate={wizard.setField}
onPatch={wizard.patchDraft} onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound} onDuplicateWoNumber={wizard.handleDuplicateFound}
@ -115,7 +80,14 @@ export function NewWoWizard({
draft={wizard.draft} draft={wizard.draft}
locations={wizard.locations} locations={wizard.locations}
locationDetail={wizard.locationDetail} locationDetail={wizard.locationDetail}
locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))} 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} onUpdate={wizard.setField}
onPatch={wizard.patchDraft} onPatch={wizard.patchDraft}
/> />
@ -135,9 +107,18 @@ export function NewWoWizard({
<WizardFooter <WizardFooter
step={wizard.step} step={wizard.step}
draft={wizard.draft} draft={wizard.draft}
onCancel={() => onOpenChange(false)} onCancel={() => {
onBack={() => wizard.setStep((s) => s - 1)} if (!wizard.isCreating) onOpenChange(false);
onContinue={() => wizard.setStep((s) => s + 1)} }}
onBack={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s - 1);
}}
onContinue={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s + 1);
}}
onAttemptContinue={() => wizard.setShowErrors(true)}
onCreate={wizard.handleCreate} onCreate={wizard.handleCreate}
isCreating={wizard.isCreating} 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) { }: UseNewWoWizardStateArgs) {
const mockFallback = isWoWizardMockEnabled(); const mockFallback = isWoWizardMockEnabled();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [showErrors, setShowErrors] = useState(false);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
status: "Incomplete", status: "Incomplete",
@ -81,6 +82,7 @@ export function useNewWoWizardState({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setStep(1); setStep(1);
setShowErrors(false);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setPocAutoFilledFor(""); setPocAutoFilledFor("");
setDuplicateRow(null); setDuplicateRow(null);
@ -119,6 +121,8 @@ export function useNewWoWizardState({
return { return {
step, step,
setStep, setStep,
showErrors,
setShowErrors,
draft, draft,
duplicateRow, duplicateRow,
setDuplicateRow, 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 { Popover } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils"; import { WizardDateFieldTrigger } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; 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"; import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WizardDateFieldProps = { type WizardDateFieldProps = {
@ -14,8 +16,29 @@ type WizardDateFieldProps = {
allowWeekMode?: boolean; allowWeekMode?: boolean;
targetWeek?: string; targetWeek?: string;
onChangeWeek?: (weekStart: string) => void; 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({ export function WizardDateField({
value, value,
placeholder, placeholder,
@ -23,60 +46,44 @@ export function WizardDateField({
allowWeekMode, allowWeekMode,
targetWeek = "", targetWeek = "",
onChangeWeek, onChangeWeek,
scheduleEnd = "",
schedMode,
onChangeRange,
}: WizardDateFieldProps) { }: WizardDateFieldProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [cellMode, setCellMode] = useState<"week" | "date">( const allowRange = Boolean(allowWeekMode && onChangeRange);
allowWeekMode && !value && targetWeek ? "week" : "date",
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
{
open,
value,
scheduleEnd,
allowRange,
allowWeekMode,
targetWeek,
schedMode,
},
); );
const showWeek = Boolean(allowWeekMode && !value && targetWeek); 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 ( return (
<> <>
<MenuAnchorButton <WizardDateFieldTrigger
onAnchor={setAnchor} onAnchor={setAnchor}
open={open} open={open}
className="flex w-full items-center gap-2 transition-colors" label={fieldLabel({ showRange, showWeek, value, scheduleEnd, targetWeek, placeholder })}
style={{ showWeek={showWeek}
height: 36, showRange={showRange}
border: "1px solid var(--color-border)", hasValue={Boolean(value)}
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)" }}
/>
) : (
<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 <Popover
open={open} open={open}
@ -91,35 +98,12 @@ export function WizardDateField({
}, },
}} }}
> >
{Boolean(allowWeekMode) && ( <WizardDateModeTabs
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}> modes={modes}
{(["week", "date"] as const).map((m) => { cellMode={cellMode}
const activeMode = cellMode === m; allowRange={allowRange}
return ( onSelect={setCellMode}
<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>
)}
{allowWeekMode && cellMode === "week" ? ( {allowWeekMode && cellMode === "week" ? (
<WeekPicker <WeekPicker
@ -130,6 +114,16 @@ export function WizardDateField({
close(); close();
}} }}
/> />
) : allowRange && cellMode === "range" ? (
<WizardDateRangePanel
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeClick={handleRangeClick}
onDone={(start, end) => {
onChangeRange?.(start, end);
close();
}}
/>
) : ( ) : (
<MiniCalendar <MiniCalendar
value={value} 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 { useState, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Popover } from "@mui/material"; import { Popover } from "@mui/material";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; 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"; import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption { export type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
key: string;
label: string;
left?: ReactNode;
}
type WizardFieldSelectProps = { type WizardFieldSelectProps = {
value: string; value: string;
@ -22,6 +19,19 @@ type WizardFieldSelectProps = {
disabled?: boolean; 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({ export function WizardFieldSelect({
value, value,
placeholder, placeholder,
@ -35,13 +45,7 @@ export function WizardFieldSelect({
const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const selected = options.find((o) => o.key === value); const selected = options.find((o) => o.key === value);
const filtered = q const filtered = filterOptions(options, q);
? options.filter(
(o) =>
o.label.toLowerCase().includes(q.toLowerCase()) ||
o.key.toLowerCase().includes(q.toLowerCase()),
)
: options;
const handleClose = () => { const handleClose = () => {
close(); close();
@ -74,9 +78,8 @@ export function WizardFieldSelect({
}} }}
aria-haspopup="listbox" aria-haspopup="listbox"
> >
{icon != null && <span className="flex shrink-0 items-center">{icon}</span>} {(selected?.left ?? icon) != null && (
{icon == null && selected?.left != null && ( <span className="flex shrink-0 items-center">{selected?.left ?? icon}</span>
<span className="flex shrink-0 items-center">{selected.left}</span>
)} )}
{selected ? ( {selected ? (
<span <span
@ -110,59 +113,18 @@ export function WizardFieldSelect({
}, },
}} }}
> >
{search && ( <WizardFieldSelectOptions
<div search={search}
className="mb-1 px-1 pb-1.5" searchPlaceholder={searchPlaceholder}
style={{ borderBottom: "1px solid var(--color-border)" }} query={q}
> onQueryChange={setQ}
<input options={filtered}
autoFocus value={value}
value={q} onPick={(key) => {
onChange={(e) => setQ(e.target.value)} onPick(key);
placeholder={searchPlaceholder} handleClose();
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);
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> </Popover>
</> </>
); );

View file

@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
import { import {
WIZARD_STEPS, WIZARD_STEPS,
canContinueWizardStep1, canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard, canCreateWorkOrderFromWizard,
isWizardStepValid, isWizardStepValid,
isWizardWoNumberValid,
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
@ -15,9 +17,24 @@ type WizardFooterProps = {
onBack: () => void; onBack: () => void;
onContinue: () => void; onContinue: () => void;
onCreate: () => void; onCreate: () => void;
onAttemptContinue?: () => void;
isCreating: boolean; 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({ export function WizardFooter({
step, step,
draft, draft,
@ -25,11 +42,12 @@ export function WizardFooter({
onBack, onBack,
onContinue, onContinue,
onCreate, onCreate,
onAttemptContinue,
isCreating, isCreating,
}: WizardFooterProps) { }: WizardFooterProps) {
const stepValid = isWizardStepValid(step, draft); const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft); const canCreate = canCreateWorkOrderFromWizard(draft);
const canContinue1 = canContinueWizardStep1(draft); const hint = stepHint(step, draft);
return ( return (
<div <div
@ -60,20 +78,25 @@ export function WizardFooter({
)} )}
<div className="flex flex-1 justify-center"> <div className="flex flex-1 justify-center">
{step === 1 && !canContinue1 && ( {Boolean(hint) && (
<span <span
className="inline-flex items-center gap-1.5" className="inline-flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--muted-foreground)" }} style={{ fontSize: 12, color: "var(--muted-foreground)" }}
> >
<Lightbulb size={13} /> Pick a type to continue <Lightbulb size={13} /> {hint}
</span> </span>
)} )}
</div> </div>
{step < WIZARD_STEPS.length ? ( {step < WIZARD_STEPS.length ? (
<Button <Button
disabled={!stepValid} onClick={() => {
onClick={onContinue} if (!stepValid) {
onAttemptContinue?.();
return;
}
onContinue();
}}
endIcon={<ChevronRight size={14} />} endIcon={<ChevronRight size={14} />}
sx={{ sx={{
background: "var(--color-primary)", background: "var(--color-primary)",
@ -81,17 +104,23 @@ export function WizardFooter({
height: 36, height: 36,
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}), ...(!stepValid ? { opacity: 0.7 } : {}),
}} }}
> >
Continue Continue
</Button> </Button>
) : ( ) : (
<Button <Button
disabled={!canCreate || isCreating} disabled={isCreating}
loading={isCreating} loading={isCreating}
loadingText="Creating…" loadingText="Creating…"
onClick={onCreate} onClick={() => {
if (!canCreate) {
onAttemptContinue?.();
return;
}
onCreate();
}}
startIcon={!isCreating ? <Check size={14} /> : undefined} startIcon={!isCreating ? <Check size={14} /> : undefined}
sx={{ sx={{
background: "var(--color-primary)", background: "var(--color-primary)",
@ -99,7 +128,7 @@ export function WizardFooter({
height: 36, height: 36,
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}), ...(!canCreate ? { opacity: 0.7 } : {}),
}} }}
> >
Create work order 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 { ClipboardList } from "lucide-react";
import { import {
WIZARD_STATUS_DOTS,
WIZARD_STEPS, WIZARD_STEPS,
type WizardWOStatus,
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
import { import { StepCircle } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
fmtDate, import { WizardSidebarSummary } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary";
fmtWeekRange,
formatApptRange,
} from "@/domain/work-orders/utils/wizard-date-utils";
import {
ColorDot,
StepCircle,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardSidebarProps = { type WizardSidebarProps = {
step: number; step: number;
@ -120,106 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
</div> </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 { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location"; import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
import { import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
DLG_INPUT_CLS, import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
DLG_INPUT_STYLE, import { WizardStepLocationSiteSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select";
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
Detail,
WizLabel,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type ServiceOption = { id: string | number; value: string }; type ServiceOption = { id: string | number; value: string };
@ -20,151 +13,66 @@ type WizardStepLocationServiceProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
locations: LocationOption[]; locations: LocationOption[];
locationDetail: Location | undefined; locationDetail: Location | undefined;
locationDetailLoading?: boolean;
locationDetailError?: boolean;
services: ServiceOption[]; 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; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => 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({ export function WizardStepLocationService({
draft, draft,
locations, locations,
locationDetail, locationDetail,
locationDetailLoading,
locationDetailError,
services, services,
showErrors = false,
svcOpen,
onSvcOpenChange,
svcSearch,
onSvcSearchChange,
onUpdate, onUpdate,
onPatch, onPatch,
}: WizardStepLocationServiceProps) { }: WizardStepLocationServiceProps) {
const selectedLoc = locations.find((l) => String(l.id) === draft.locationId);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <WizardStepLocationSiteSelect
<WizLabel required>Site</WizLabel> locationId={draft.locationId}
<WizardFieldSelect locations={locations}
value={draft.locationId} onPatch={onPatch}
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 ?? "",
});
}}
/>
{!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 <WizardStepLocationSiteDetails
className="space-y-1.5 rounded-lg p-3" selectedLoc={selectedLoc}
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }} locationName={draft.locationName}
> locationDetail={locationDetail}
<Detail locationDetailLoading={locationDetailLoading}
icon={MapPin} locationDetailError={locationDetailError}
text={ />
[ <WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
locationDetail.address,
locationDetail.city,
locationDetail.state,
locationDetail.zipCode,
]
.filter(Boolean)
.join(", ") || "—"
}
/>
{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>
</> </>
)} )}
<div> <WizardStepLocationServiceSelect
<WizLabel optional>Service</WizLabel> draft={draft}
<WizardFieldSelect services={services}
value={draft.pm} svcOpen={svcOpen}
placeholder="Select service…" onSvcOpenChange={onSvcOpenChange}
icon={ svcSearch={svcSearch}
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} /> onSvcSearchChange={onSvcSearchChange}
} onUpdate={onUpdate}
searchPlaceholder="Search PM type…" onPatch={onPatch}
options={services.map((s) => ({ />
key: s.value,
label: s.value,
}))}
onPick={(p) => onUpdate("pm", p)}
/>
</div>
{!draft.locationId && !draft.pm && ( {!draft.locationId && !draft.pm && (
<Text <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 type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; 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 { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
@ -14,6 +24,7 @@ import {
WizLabel, WizLabel,
WizLabelRow, WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; } 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 { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react"; import type { ElementType } from "react";
@ -25,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
Overdue: Clock, Overdue: Clock,
}; };
function nextSeverityForType(
currentType: WOType | "",
nextType: WOType,
currentSeverity: WorkOrderSeverity | null,
): WorkOrderSeverity | null {
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
return null;
}
return currentSeverity;
}
function WoTypePicker({ function WoTypePicker({
selected, selected,
onSelect, onSelect,
@ -112,9 +134,92 @@ function WoTypePicker({
); );
} }
type WizardTypeScheduleDatesProps = {
draft: WorkOrderWizardDraft;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
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>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
);
}
type WizardStepTypeScheduleProps = { type WizardStepTypeScheduleProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
users: WorkOrderUser[]; users: WorkOrderUser[];
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void; onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void; onDuplicateWoNumber?: (woNumber: string) => void;
@ -123,29 +228,57 @@ type WizardStepTypeScheduleProps = {
export function WizardStepTypeSchedule({ export function WizardStepTypeSchedule({
draft, draft,
users, users,
showErrors = false,
onUpdate, onUpdate,
onPatch, onPatch,
onDuplicateWoNumber, onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) { }: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type);
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<WizLabel required>Type of WO</WizLabel> <WizLabel optional>Type of WO</WizLabel>
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} /> <WoTypePicker
selected={draft.type}
onSelect={(t) => {
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
});
}}
/>
</div> </div>
{Boolean(needsSeverity) && (
<WizardSeverityPicker
value={draft.severity}
showError={showErrors}
onSelect={(severity) => onUpdate("severity", severity)}
/>
)}
<div> <div>
<WizLabel optional>WO Number</WizLabel> <WizLabel optional>WO Number</WizLabel>
<input <input
type="text" type="text"
value={draft.woNumber} value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value)} onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 24818" placeholder="Leave blank for SH provisional number"
maxLength={20} maxLength={20}
className={DLG_INPUT_CLS} className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE} style={DLG_INPUT_STYLE}
/> />
{Boolean(woNumberError) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
WO number must be alphanumeric.
</Text>
)}
</div> </div>
<div> <div>
@ -154,13 +287,17 @@ export function WizardStepTypeSchedule({
value={draft.assigneeId} value={draft.assigneeId}
placeholder="Select…" placeholder="Select…"
icon={ icon={
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} /> selectedUser ? (
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
) : (
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
} }
searchPlaceholder="Search dispatcher…" searchPlaceholder="Search dispatcher…"
options={users.map((u) => ({ options={users.map((u) => ({
key: String(u.id), key: String(u.id),
label: u.name, label: u.name,
left: <ColorDot color="var(--color-primary)" />, left: <ColorDot color={getDispatcherAvatarColor(u)} />,
}))} }))}
onPick={(id) => { onPick={(id) => {
const user = users.find((u) => String(u.id) === id); const user = users.find((u) => String(u.id) === id);
@ -172,43 +309,7 @@ export function WizardStepTypeSchedule({
/> />
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly
? () => onPatch({ scheduledOn: "", scheduleWeekOnly: false })
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
</div> </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 { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { import {
ALL_WIZARD_STATUSES,
TIME_SLOTS, TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils"; 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 { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
DLG_INPUT_STYLE, DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; } from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { import {
ColorDot,
Detail, Detail,
WizLabel, WizLabel,
WizLabelRow, WizLabelRow,
@ -33,11 +33,13 @@ function WizardVendorDetail({
vendor, vendor,
techPhone, techPhone,
vendorNotes, vendorNotes,
distance,
onUpdate, onUpdate,
}: { }: {
vendor: VendorDropdownItem; vendor: VendorDropdownItem;
techPhone: string; techPhone: string;
vendorNotes: string; vendorNotes: string;
distance: string;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
}) { }) {
return ( return (
@ -61,9 +63,20 @@ function WizardVendorDetail({
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
}} }}
> >
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}> <div className="flex items-start justify-between gap-2">
{vendor.companyName || "—"} <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> </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) && <Detail icon={Mail} text={vendor.address} />}
{Boolean(vendor.address) && ( {Boolean(vendor.address) && (
<a <a
@ -100,8 +113,7 @@ export function WizardStepVendorTime({
onPatch, onPatch,
}: WizardStepVendorTimeProps) { }: WizardStepVendorTimeProps) {
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId); const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus; const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -133,7 +145,9 @@ export function WizardStepVendorTime({
color: "var(--color-text-secondary)", color: "var(--color-text-secondary)",
}} }}
> >
{selectedVendor.companyName.slice(0, 2).toUpperCase()} {(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span> </span>
) : ( ) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} /> <Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
@ -142,7 +156,7 @@ export function WizardStepVendorTime({
searchPlaceholder="Search technician…" searchPlaceholder="Search technician…"
options={vendors.map((v) => ({ options={vendors.map((v) => ({
key: String(v.id), key: String(v.id),
label: v.companyName, label: formatVendorTechnicianLabel(v),
}))} }))}
onPick={(id) => { onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id); const vendor = vendors.find((v) => String(v.id) === id);
@ -160,6 +174,7 @@ export function WizardStepVendorTime({
vendor={selectedVendor} vendor={selectedVendor}
techPhone={draft.techPhone} techPhone={draft.techPhone}
vendorNotes={draft.vendorNotes} vendorNotes={draft.vendorNotes}
distance={selectedDistance}
onUpdate={onUpdate} onUpdate={onUpdate}
/> />
)} )}
@ -213,22 +228,6 @@ export function WizardStepVendorTime({
</Text> </Text>
)} )}
</div> </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> </div>
); );
} }

View file

@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useForm, useWatch } from "react-hook-form"; import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router"; 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 { Text } from "@/components/ui/text";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; 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 { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper"; import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
@ -28,6 +29,7 @@ import {
WorkOrderDetailsFields, WorkOrderDetailsFields,
} from "@/app/(protected)/workorders/_components/work-order-fields"; } from "@/app/(protected)/workorders/_components/work-order-fields";
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section"; import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
type ScheduleBounds = { type ScheduleBounds = {
kind: "valid" | "invalid" | "open"; kind: "valid" | "invalid" | "open";
@ -125,7 +127,12 @@ function useWorkOrderFormState() {
const isEdit = Boolean(id); const isEdit = Boolean(id);
const assignVendorRequested = searchParams.get("assignVendor") === "1"; 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: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown(); const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
@ -225,6 +232,7 @@ function useWorkOrderFormState() {
detail, detail,
isLoading, isLoading,
detailError, detailError,
refetchDetail,
isSaving: saveMutation.isPending, isSaving: saveMutation.isPending,
register, register,
control, control,
@ -264,6 +272,7 @@ export default function WorkOrderFormPage() {
detail, detail,
isLoading, isLoading,
detailError, detailError,
refetchDetail,
isSaving, isSaving,
register, register,
control, control,
@ -296,17 +305,16 @@ export default function WorkOrderFormPage() {
} = useWorkOrderFormState(); } = useWorkOrderFormState();
if (isLoading) { if (isLoading) {
return ( return <DetailPageSkeleton variant="form" />;
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
} }
if (detailError) { if (detailError) {
return ( return (
<Box className="p-4"> <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> </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 { 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 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 { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { import {
useAddChecklistItem, useAddChecklistItem,
@ -53,8 +47,8 @@ export function useDispatchDetailController(
workOrderId: string | number, workOrderId: string | number,
) { ) {
const { user } = useAuthContext(); const { user } = useAuthContext();
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId); const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId); const uplift = useDispatchUpliftController(dispatchId);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DispatchEditFormState>({ const [form, setForm] = useState<DispatchEditFormState>({
@ -68,10 +62,6 @@ export function useDispatchDetailController(
const [commentText, setCommentText] = useState(""); const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true); const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null); 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 updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
const addCommentMutation = useAddDispatchComment(dispatchId); const addCommentMutation = useAddDispatchComment(dispatchId);
@ -79,9 +69,6 @@ export function useDispatchDetailController(
const addChecklistMutation = useAddChecklistItem(dispatchId); const addChecklistMutation = useAddChecklistItem(dispatchId);
const addSignoffMutation = useAddDispatchSignoff(dispatchId); const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId); const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId);
const rejectUpliftMutation = useRejectUplift(dispatchId);
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
const userName = const userName =
(typeof user?.name === "string" ? user.name : undefined) ?? (typeof user?.name === "string" ? user.name : undefined) ??
@ -140,30 +127,22 @@ export function useDispatchDetailController(
); );
}; };
const handleUpliftConfirm = (note: string) => { const isMutating =
if (!upliftAction) { updateDispatchMutation.isPending ||
return; addCommentMutation.isPending ||
} updateChecklistMutation.isPending ||
addChecklistMutation.isPending ||
const id = upliftAction.uplift.id; addSignoffMutation.isPending ||
if (upliftAction.type === "approve") { verifyMutation.isPending ||
approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) }); uplift.isMutating;
return;
}
if (!note.trim()) {
return;
}
const mutation =
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
};
return { return {
dispatch, dispatch,
isLoading, isLoading,
uplifts, isFetching,
refetch,
uplifts: uplift.uplifts,
isLoadingUplifts: uplift.isLoadingUplifts,
editing, editing,
form, form,
setForm, setForm,
@ -175,25 +154,28 @@ export function useDispatchDetailController(
setSendEmail, setSendEmail,
sigCaptureType, sigCaptureType,
setSigCaptureType, setSigCaptureType,
upliftAction, upliftAction: uplift.upliftAction,
setUpliftAction, setUpliftAction: uplift.setUpliftAction,
isLocked, isLocked,
isMutating,
userName, userName,
verifyState, verifyState,
beginEditing, beginEditing,
handleSave, handleSave,
handleCancelDispatch, handleCancelDispatch,
handleComment, handleComment,
handleUpliftConfirm, handleUpliftConfirm: uplift.handleUpliftConfirm,
setEditing, setEditing,
updateDispatchMutation, updateDispatchMutation,
updateChecklistMutation, updateChecklistMutation,
addChecklistMutation, addChecklistMutation,
addSignoffMutation, addSignoffMutation,
verifyMutation, verifyMutation,
approveUpliftMutation, approveUpliftMutation: uplift.approveUpliftMutation,
rejectUpliftMutation, rejectUpliftMutation: uplift.rejectUpliftMutation,
requestChangesUpliftMutation, requestChangesUpliftMutation: uplift.requestChangesUpliftMutation,
addCommentMutation, 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 type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -19,6 +19,12 @@ type SessionUploadRequest = {
signal: AbortSignal; signal: AbortSignal;
}; };
function revokeBlobUrl(url: string | undefined) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function toLocalUpload(request: SessionUploadRequest): LocalUpload { function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file); const classified = classifyUpload(request.file);
return { return {
@ -39,10 +45,33 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal; return item.id === request.localId && item.signal === request.signal;
} }
function revokeBlobUrl(url: string) { function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] {
if (url.startsWith("blob:")) { const next = [...previous];
URL.revokeObjectURL(url); for (const entry of entries) {
const index = next.findIndex((item) => item.id === entry.id);
if (index >= 0) {
revokeBlobUrl(next[index]?.url);
next[index] = entry;
} else {
next.push(entry);
}
} }
return next;
}
function dropUploads(
previous: LocalUpload[],
matches: (item: LocalUpload) => boolean,
): LocalUpload[] {
const kept: LocalUpload[] = [];
for (const item of previous) {
if (matches(item)) {
revokeBlobUrl(item.url);
continue;
}
kept.push(item);
}
return kept;
} }
export function useMediaUploadSession( export function useMediaUploadSession(
@ -55,7 +84,7 @@ export function useMediaUploadSession(
const previousWorkOrderIdRef = useRef(workOrderId); const previousWorkOrderIdRef = useRef(workOrderId);
// Drop local tiles and abort in-flight uploads when the bound WO changes. // 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(() => { useEffect(() => {
if (previousWorkOrderIdRef.current === workOrderId) return; if (previousWorkOrderIdRef.current === workOrderId) return;
const hadPrevious = previousWorkOrderIdRef.current != null; const hadPrevious = previousWorkOrderIdRef.current != null;
@ -66,12 +95,7 @@ export function useMediaUploadSession(
controller.abort(); controller.abort();
} }
controllersRef.current.clear(); controllersRef.current.clear();
setLocalUploads((prev) => { setLocalUploads((prev) => dropUploads(prev, () => true));
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
}, [workOrderId]); }, [workOrderId]);
const serverUploads = useMemo( const serverUploads = useMemo(
@ -98,6 +122,27 @@ export function useMediaUploadSession(
return controller; return controller;
} }
function settleAttempt(request: SessionUploadRequest) {
clearController(request.localId);
setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request)));
}
function failAttempt(request: SessionUploadRequest, error: Error) {
// Keep the controller entry while the attempt is retryable; retry replaces it.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
}
function startUploads(requests: MediaUploadRequest[]) { function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({ const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId, localId: request.localId,
@ -105,23 +150,8 @@ export function useMediaUploadSession(
signal: replaceController(request.localId).signal, signal: replaceController(request.localId).signal,
})); }));
setLocalUploads((prev) => { const entries = sessionRequests.map(toLocalUpload);
const next = [...prev]; setLocalUploads((prev) => upsertUploads(prev, entries));
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
});
for (const request of sessionRequests) { for (const request of sessionRequests) {
void uploadMutateAsync({ void uploadMutateAsync({
@ -135,29 +165,13 @@ export function useMediaUploadSession(
); );
}, },
}) })
.then(() => { .then(() => settleAttempt(request))
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.catch((error: Error) => { .catch((error: Error) => {
if (error.name === "AbortError") { if (error.name === "AbortError") {
clearController(request.localId); settleAttempt(request);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return; return;
} }
// Keep controller entry only while retryable; replaceController on retry. failAttempt(request, error);
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
}); });
} }
} }
@ -174,21 +188,19 @@ export function useMediaUploadSession(
]); ]);
} }
function cancelUpload(localId: string) { /** Abort any in-flight attempt for the tile and drop it from the session. */
function dropLocal(localId: string) {
controllersRef.current.get(localId)?.abort(); controllersRef.current.get(localId)?.abort();
clearController(localId); clearController(localId);
setLocalUploads((prev) => { setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
const target = prev.find((item) => item.id === localId); }
if (target) {
revokeBlobUrl(target.url); function cancelUpload(localId: string) {
} dropLocal(localId);
return prev.filter((item) => item.id !== localId);
});
} }
function removeLocal(id: string) { function removeLocal(id: string) {
clearController(id); dropLocal(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
} }
function isLocal(id: string) { function isLocal(id: string) {

View file

@ -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 { guardDialogClose } from "@/lib/dialog-guard";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { import {
Box,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton, IconButton,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material"; } from "@mui/material";
import { SignatureCaptureFields } from "@/components/common/signature-capture-fields";
import { useSignatureCanvas } from "@/components/common/use-signature-canvas";
interface SignatureCaptureProps { interface SignatureCaptureProps {
title?: string; title?: string;
onClose: () => void; onClose: () => void;
onSave: (signature: string, method: "drawn" | "typed", name: string) => 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 [mode, setMode] = useState<"draw" | "type">("draw");
const [typedName, setTypedName] = useState(""); const [typedName, setTypedName] = useState("");
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const canvasRef = useRef<HTMLCanvasElement>(null); const { canvasRef, startDraw, draw, stopDraw, clearCanvas } = useSignatureCanvas(mode);
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 handleSave = () => { const handleSave = () => {
if (!signerName.trim()) { if (isPending || !signerName.trim()) {
return; return;
} }
@ -134,77 +53,41 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
}; };
const dialog = ( 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"> <DialogTitle className="flex items-center justify-between pr-2">
{title || "Capture Signature"} {title || "Capture Signature"}
<IconButton aria-label="Close" onClick={onClose} size="small"> <IconButton aria-label="Close" onClick={onClose} size="small" disabled={isPending}>
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</DialogTitle> </DialogTitle>
<DialogContent className="flex flex-col gap-4"> <DialogContent className="flex flex-col gap-4">
<TextField <SignatureCaptureFields
label="Full Name" isPending={isPending}
value={signerName} mode={mode}
onChange={(event) => setSignerName(event.target.value)} onModeChange={setMode}
placeholder="Enter signer's full name" signerName={signerName}
fullWidth onSignerNameChange={setSignerName}
size="small" 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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleSave} onClick={handleSave}
disabled={!signerName.trim() || (mode === "type" && !typedName.trim())} disabled={isPending || !signerName.trim() || (mode === "type" && !typedName.trim())}
> >
Save Signature {isPending ? "Saving…" : "Save Signature"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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 { API_PATHS } from "@/api/api-paths";
import { apiPost } from "@/api/api"; import { apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response"; 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 { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type { import type {
AuthUser, AuthUser,
@ -27,18 +22,10 @@ export async function login(credentials: LoginCredentials): Promise<AuthUser> {
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
if (isAuthBypassEnabled()) {
disableAuthBypassForSession();
}
clearAuth(); clearAuth();
} }
export async function getSession(): Promise<AuthUser | null> { export async function getSession(): Promise<AuthUser | null> {
if (isAuthBypassEnabled()) {
return getBypassAuthUser();
}
const user = getAuthUser(); const user = getAuthUser();
if (!user || !isSessionValid(user)) { 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 * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user"; import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export function useSession(): UseQueryResult<AuthUser | null, Error> { export function useSession(): UseQueryResult<AuthUser | null, Error> {
return useQuery({ return useQuery({
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials), mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true }, meta: { suppressErrorToast: true },
onSuccess: (user) => { onSuccess: (user) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user); queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
toast.success("Login successful!"); toast.success("Login successful!");
navigate("/"); navigate("/");
}, },
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
return useMutation({ return useMutation({
mutationFn: authApi.logout, mutationFn: authApi.logout,
onSuccess: () => { onSuccess: () => {
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
toast.info("You have been logged out"); toast.info("You have been logged out");
navigate("/login"); navigate("/login");
}, },
onError: (error: Error) => { onError: (error: Error) => {
queryClient.setQueryData(queryKeys.auth.session(), null); stopQueryBroadcast(queryClient);
toast.error(error.message || "Logout failed"); toast.error(error.message || "Logout failed");
navigate("/login"); 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 type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; 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( export function assignWizardScheduleFields(
payload: WorkOrderBoardCreatePayload, payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft, draft: WorkOrderWizardDraft,
@ -21,7 +25,7 @@ export function assignWizardScheduleFields(
payload.scheduleWeekOnly = true; payload.scheduleWeekOnly = true;
payload.targetWeek = week; payload.targetWeek = week;
} }
} else if (!isRange && draft.scheduledOn) { } else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {
payload.scheduledDate = draft.scheduledOn; payload.scheduledDate = draft.scheduledOn;
} }
} }

View file

@ -7,42 +7,7 @@ function trimOrUndefined(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined;
} }
function normalizeExtraServices( /** Mutates `payload` with primary service, service notes, POC, tech phone, and vendor notes. */
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. */
export function assignWizardServicePocFields( export function assignWizardServicePocFields(
payload: WorkOrderBoardCreatePayload, payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft, draft: WorkOrderWizardDraft,
@ -53,12 +18,6 @@ export function assignWizardServicePocFields(
payload.primaryService = primaryService; payload.primaryService = primaryService;
} }
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
if (extraServices) {
payload.extraServices = extraServices;
}
const serviceNotes = trimOrUndefined(draft.pmNote); const serviceNotes = trimOrUndefined(draft.pmNote);
if (serviceNotes) { if (serviceNotes) {
@ -94,4 +53,8 @@ export function assignWizardServicePocFields(
if (vendorNotes) { if (vendorNotes) {
payload.vendorNotes = 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. */ /** One row per scalar patch field, in emission order. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string }, { 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: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string }, { key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", 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 { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type { import {
WOType, effectiveWizardType,
WizardWOStatus, type WOType,
WorkOrderWizardDraft, type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; 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 { function buildTitle(draft: WorkOrderWizardDraft): string {
const typeLabel = effectiveWizardType(draft.type);
if (draft.woNumber) { if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`; return `${typeLabel} — #${draft.woNumber}`;
} }
if (draft.locationName) { 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 { function buildDescription(draft: WorkOrderWizardDraft): string {
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
workOrderNumber: draft.woNumber, workOrderNumber: draft.woNumber,
title: buildTitle(draft), title: buildTitle(draft),
locationId: draft.locationId, locationId: draft.locationId,
priority: mapTypeToPriority(draft.type), priority: mapTypeToPriority(effectiveWizardType(draft.type)),
status: mapWizardStatusToApi(draft.status), status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(), createdDate: todayIso(),
dueDate: draft.dueDate, dueDate: draft.dueDate,
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
trade: "", trade: "",
subTrade: "", subTrade: "",
vendorNTE: "", vendorNTE: "",
source: draft.type, source: effectiveWizardType(draft.type),
}; };
} }

View file

@ -3,9 +3,16 @@ import type {
WorkOrderTypeApi, WorkOrderTypeApi,
} from "@/domain/work-orders/types/work-order-board"; } 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"; import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -80,7 +87,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
export function mapWizardDraftToBoardCreatePayload( export function mapWizardDraftToBoardCreatePayload(
draft: WorkOrderWizardDraft, draft: WorkOrderWizardDraft,
): WorkOrderBoardCreatePayload { ): WorkOrderBoardCreatePayload {
const workOrderType = mapWizardTypeToApi(draft.type); const workOrderType = mapWizardTypeToApi(draft.type || "PM");
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName }); const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
@ -98,6 +105,10 @@ export function mapWizardDraftToBoardCreatePayload(
siteCode, siteCode,
}; };
if (draft.status) {
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
}
if (draft.woNumber.trim()) { if (draft.woNumber.trim()) {
payload.woNumber = draft.woNumber.trim(); payload.woNumber = draft.woNumber.trim();
} }

View file

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

View file

@ -1,10 +1,9 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
export function isWoWizardMockEnabled(): boolean { export function isWoWizardMockEnabled(): boolean {
return env.isDev; return env.isDev;
} }
export function shouldUseMockWoWizardCreate(locationId: string): boolean { 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 { export interface WorkOrderBoardCreatePayload {
workOrderType: WorkOrderTypeApi; workOrderType: WorkOrderTypeApi;
siteCode: string; siteCode: string;
/** Lifecycle status selected in the wizard; numeric board enum. */
lifecycleStatus?: LifecycleStatusApi;
woNumber?: string; woNumber?: string;
assignTo?: string; assignTo?: string;
dueDate?: string; dueDate?: string;
@ -91,6 +93,8 @@ export interface WorkOrderBoardCreatePayload {
pocNotes?: string; pocNotes?: string;
techPhone?: string; techPhone?: string;
vendorNotes?: string; vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number;
} }
/** Payload for PATCH /workorders/{id}/board (one field per request). */ /** Payload for PATCH /workorders/{id}/board (one field per request). */
@ -120,7 +124,8 @@ export type WorkOrderBoardEditableField =
| "pm" | "pm"
| "serviceNotes" | "serviceNotes"
| "extraServices" | "extraServices"
| "flagColor"; | "flagColor"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */ /** Espelha WorkOrderBoardRowDto (JSON camelCase). */
export interface WorkOrderBoardRowApi { export interface WorkOrderBoardRowApi {
@ -145,6 +150,8 @@ export interface WorkOrderBoardRowApi {
color?: string | null; color?: string | null;
/** Board flag color (#RRGGBB) or null when cleared. */ /** Board flag color (#RRGGBB) or null when cleared. */
flagColor?: string | null; flagColor?: string | null;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number | null;
dueDate?: string | null; dueDate?: string | null;
scheduledDate?: string | null; scheduledDate?: string | null;
/** ISO date when work was completed; optional on list rows until BE confirms. */ /** 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 { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
woNumber: string; woNumber: string;
site: string; site: string;
type: WOType | ""; type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
dispatcherId: string; dispatcherId: string;
dispatcherName: string; dispatcherName: string;
scheduledOn: string; scheduledOn: string;
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
site: string; site: string;
woNumber: string; woNumber: string;
type: WOType | ""; type: WOType | "";
severity: WorkOrderSeverity | null;
dispatcherId: string; dispatcherId: string;
scheduledOn: string; scheduledOn: string;
schedNotes: string; schedNotes: string;
@ -84,6 +88,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "", site: "",
woNumber: "", woNumber: "",
type: "", type: "",
severity: null,
dispatcherId: "", dispatcherId: "",
scheduledOn: "", scheduledOn: "",
schedNotes: "", 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 const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
export type WOType = (typeof WO_TYPES)[number]; export type WOType = (typeof WO_TYPES)[number];
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
siteCode: string; siteCode: string;
woNumber: string; woNumber: string;
type: WOType | ""; type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
assigneeId: string; assigneeId: string;
assigneeName: string; assigneeName: string;
scheduledOn: string; scheduledOn: string;
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
siteCode: "", siteCode: "",
woNumber: "", woNumber: "",
type: "", type: "",
severity: null,
assigneeId: "", assigneeId: "",
assigneeName: "", assigneeName: "",
scheduledOn: "", scheduledOn: "",
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
schedMode: undefined, 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 { export function isWizardWoNumberValid(woNumber: string): boolean {
const trimmed = woNumber.trim(); const trimmed = woNumber.trim();
if (!trimmed) { if (!trimmed) {
return true; 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 }[] = [ 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 { 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 { 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 { export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
return canContinueWizardStep1(draft); return canContinueWizardStep1(draft);
} }
if (step === 2) { if (step === 2) {
return Boolean(draft.locationId && draft.siteCode); return canContinueWizardStep2(draft);
} }
return true; 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 = { export const env = {
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api", apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
isDev: import.meta.env.DEV, isDev: import.meta.env.DEV,
authBypass: import.meta.env.VITE_AUTH_BYPASS === "true",
} as const; } 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 { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login"; 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"; import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const sessionQuery = useSession(); const sessionQuery = useSession();
const loginMutation = useLogin(); const loginMutation = useLogin();
const logoutMutation = useLogout(); const logoutMutation = useLogout();
const changePasswordMutation = useChangePassword(); const changePasswordMutation = useChangePassword();
const userId = sessionQuery.data?.id;
useEffect(() => {
if (userId == null) {
return;
}
startQueryBroadcast(queryClient, userId);
}, [queryClient, userId]);
const value = useMemo<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
user: sessionQuery.data, user: sessionQuery.data,

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