mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
feat(work-orders): query broadcast, remove auth bypass, dispatch polish (#100)
* fix(work-orders): address wizard create review findings [recover] remove malicious eslint payload (was166c63e4) * fix(work-orders): remove legacy completedDate EditWorkorder path [recover] remove malicious eslint payload (wasf4e6132b) * fix(work-orders): use local calendar day for completedDate Restore todayIso() after parent merge reintroduced UTC slice, and keep the wizard-date-utils regression test. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(work-orders): send wizard status and clear POC notes on site change Map draft.status to lifecycleStatus on board create, and reset pocNotes with POC fields when the wizard site changes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(query): scope broadcast channel per account and clear on auth Isolate TanStack Query broadcast by userId, dispose and clear on logout/401, and extract vendor filter drawers for governance. * feat(work-orders): board wizard create with service notes Co-authored-by: Cursor <cursoragent@cursor.com> * fix(work-orders): enforce single-service wizard create per SH-118 Use single service selection in the wizard and omit extraServices from board create. * fix(vendors): restore filter drawer labels for e2e and visual CI Use VendorFilterOptions again and keep the Apply filters footer label expected by vendor Playwright specs and the committed visual baseline. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ee90d73a44
commit
4af826a1e0
68 changed files with 2869 additions and 1229 deletions
91
docs/adr/0001-query-broadcast-client.md
Normal file
91
docs/adr/0001-query-broadcast-client.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 0001. Cross-tab QueryClient sync via `@tanstack/query-broadcast-client-experimental`
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA is frequently used with multiple browser tabs open
|
||||
against the same work-order board (e.g. a dispatcher triaging the board in one
|
||||
tab while editing a work order in another). Each tab owns its own TanStack
|
||||
Query `QueryClient` cache, so a mutation performed in one tab (status change,
|
||||
dispatch creation, comment, vendor patch, etc.) does not invalidate or update
|
||||
the cache in sibling tabs. Users were seeing stale board/detail data until a
|
||||
manual refresh or the next background refetch.
|
||||
|
||||
We need a way to keep `QueryClient` caches roughly in sync across tabs of the
|
||||
same origin, without introducing a new state-management layer (Redux is
|
||||
disallowed by this repo's conventions) or a server-push mechanism.
|
||||
|
||||
## Decision
|
||||
|
||||
Use TanStack's own experimental broadcast client,
|
||||
[`@tanstack/query-broadcast-client-experimental`](https://tanstack.com/query),
|
||||
wired up in a single dedicated setup module,
|
||||
`src/lib/query/setup-query-broadcast.ts`. The module:
|
||||
|
||||
- Exposes `startQueryBroadcast(queryClient, userId)` and
|
||||
`stopQueryBroadcast(queryClient)`.
|
||||
- Keys `broadcastQueryClient` to an **account-scoped** channel
|
||||
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
|
||||
on the same origin do not share cache traffic.
|
||||
- Disposes the prior subscription (the unsubscribe returned by
|
||||
`broadcastQueryClient`) and clears the `QueryClient` when the authenticated
|
||||
user changes or the session ends.
|
||||
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
|
||||
(`import.meta.env.MODE === "test"`).
|
||||
- Is started after authentication (`AuthProvider` session restore + login
|
||||
success) and stopped on logout and HTTP 401 session clear — not at
|
||||
`QueryClient` module load.
|
||||
|
||||
This piggybacks on the query cache we already have (no parallel store), uses
|
||||
the library that owns the `QueryClient` we already depend on, and keeps the
|
||||
integration isolated to auth/session boundaries.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Custom `BroadcastChannel` + manual `queryClient.invalidateQueries` calls**
|
||||
— full control over payloads, but requires hand-rolling
|
||||
serialization/versioning of query keys and mutation results, and keeping
|
||||
every future mutation hook wired to broadcast. More code to own and more
|
||||
surface area for subtle cache-desync bugs.
|
||||
- **Constant channel from module load** — simplest wiring, but shares one
|
||||
channel across all sessions on the origin; after logout/login or multi-account
|
||||
use, cached work-order/vendor data can leak into the next session. Rejected.
|
||||
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
|
||||
unresolved; users would need to manually refresh or wait for
|
||||
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
||||
for a live dispatch board.
|
||||
- **Full WebSocket-based real-time sync** — solves both cross-tab and
|
||||
cross-user staleness, but is a materially larger investment (server-side
|
||||
push infra, connection lifecycle, auth over the socket) that is out of scope
|
||||
for the current admin SPA and not justified by the actual problem (same
|
||||
browser, same user, same origin).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive**: sibling tabs for the same authenticated account reflect
|
||||
mutations (status changes, dispatch actions, comments, patches) without a
|
||||
manual refresh; logout/401 tear down the broadcaster and clear sensitive
|
||||
cache so the next account cannot inherit prior data.
|
||||
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
|
||||
the TanStack ecosystem; its API may change or be deprecated between minor
|
||||
versions. `@tanstack/query-broadcast-client-experimental`,
|
||||
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
||||
the **exact same version** in `package.json` (no `^` range). All three
|
||||
resolve to the identical `@tanstack/query-core` version at that pin, which
|
||||
keeps the `QueryClient` type used by the broadcast helpers structurally
|
||||
identical to the one constructed in `query-client.ts` — a caret range lets
|
||||
npm resolve the broadcast client and React Query against two different
|
||||
`query-core` versions independently, which breaks `QueryClient` type
|
||||
identity (TS2322) even though both packages build fine in isolation. Bump
|
||||
all three together and re-evaluate on every TanStack Query upgrade.
|
||||
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
||||
origin; it does not sync across different users/sessions or devices. Account
|
||||
scoping further limits sync to tabs of the same `userId`.
|
||||
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
||||
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
|
||||
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
|
||||
`api.ts`. No other code depends on it, so rollback is isolated with no data
|
||||
migration.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# Pending BE contract (WO design parity gaps)
|
||||
|
||||
Local checklist for FE branches off `dev`. Confirm with backend before shipping PR3/PR5 to production. Until confirmed, FE may use typed clients + mocks.
|
||||
|
||||
## Board row / PATCH
|
||||
|
||||
| Field | Needed by | Notes |
|
||||
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
|
||||
| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date |
|
||||
| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` |
|
||||
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` |
|
||||
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
|
||||
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
|
||||
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
|
||||
|
||||
## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue)
|
||||
|
||||
| Method | Path | Body |
|
||||
| ------ | ------------------------------------------- | --------------------------------- |
|
||||
| GET | `workorders/{id}/uplifts` | — |
|
||||
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` |
|
||||
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — |
|
||||
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` |
|
||||
|
||||
Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`.
|
||||
|
||||
## Search query params (`GET workorders/board/search`)
|
||||
|
||||
| Param | Meaning |
|
||||
| ------------------------------ | ------------------------- |
|
||||
| `severities` | multi `1`–`5` |
|
||||
| `rescheduled` / `carriedOver` | boolean (≥2 counters) |
|
||||
| `addOn` | boolean (`isAddOn`) |
|
||||
| `flagColors` | multi `#RRGGBB` |
|
||||
| `internalOnly` | WO# starts with `SH` |
|
||||
| `avetaOnly` | if `avetaRequired` exists |
|
||||
| `hasUplift` + `upliftStatuses` | uplift filters |
|
||||
|
||||
Already documented in `board-search-api.md`: datePreset, sites, types, overdue, dispatchers, statuses, pmTypes, vendorIds, docStatuses.
|
||||
|
||||
## Comments
|
||||
|
||||
| Field | Needed by |
|
||||
| ---------------------------------------- | --------- |
|
||||
| POST `{ text, mentions?: string[] }` | PR9 |
|
||||
| Response includes mentions for highlight | PR9 |
|
||||
|
||||
## Vendor
|
||||
|
||||
| Item | Needed by |
|
||||
| -------------------------------------------------------------- | --------- |
|
||||
| Create technician under company (confirm existing vendors API) | PR7 |
|
||||
|
||||
## Optional
|
||||
|
||||
| Item | Needed by |
|
||||
| ----------------------------------- | -------------------------------- |
|
||||
| 422 on duplicate WO number (global) | PR8 (FE dialog works without it) |
|
||||
104
package-lock.json
generated
104
package-lock.json
generated
|
|
@ -21,8 +21,9 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
@ -2332,6 +2333,20 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-broadcast-client-experimental": {
|
||||
"version": "5.101.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz",
|
||||
"integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.2",
|
||||
"broadcast-channel": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
||||
|
|
@ -3214,6 +3229,30 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/broadcast-channel": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz",
|
||||
"integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.28.6",
|
||||
"oblivious-set": "2.0.0",
|
||||
"p-queue": "6.6.2",
|
||||
"unload": "2.4.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/pubkey"
|
||||
}
|
||||
},
|
||||
"node_modules/broadcast-channel/node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.6",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||
|
|
@ -5345,6 +5384,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oblivious-set": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz",
|
||||
"integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
|
|
@ -5393,6 +5441,15 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
|
|
@ -5425,6 +5482,40 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "6.6.2",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
|
||||
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.4",
|
||||
"p-timeout": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-timeout": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
|
||||
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-finally": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -6401,6 +6492,15 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unload": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
|
||||
"integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/pubkey"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
|
|
@ -48,7 +47,7 @@ export const api: KyInstance = ky.create({
|
|||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||
afterResponse: [
|
||||
({ request, response }) => {
|
||||
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
|
||||
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
|
|||
248
src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx
vendored
Normal file
248
src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||
import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material";
|
||||
import type {
|
||||
VendorFacetCompany,
|
||||
VendorFacetJobBucket,
|
||||
VendorFacetLocation,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import type { VendorFilters } from "./vendor-filters";
|
||||
import { FilterSection } from "./vendor-filters-drawer-section";
|
||||
|
||||
interface VendorFiltersDrawerFieldsProps {
|
||||
draft: VendorFilters;
|
||||
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||
options: {
|
||||
companies: VendorFacetCompany[];
|
||||
trades: string[];
|
||||
locations: VendorFacetLocation[];
|
||||
jobBuckets: VendorFacetJobBucket[];
|
||||
};
|
||||
}
|
||||
|
||||
function CompanyFilterFields({
|
||||
draft,
|
||||
setDraft,
|
||||
companyOptions,
|
||||
}: {
|
||||
draft: VendorFilters;
|
||||
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||
companyOptions: VendorFacetCompany[];
|
||||
}) {
|
||||
return (
|
||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Select vendor companies
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
companies:
|
||||
current.companies.length === companyOptions.length
|
||||
? []
|
||||
: companyOptions.map((company) => company.name),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||
? "Clear all"
|
||||
: "Select all"}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={companyOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.name,
|
||||
);
|
||||
setDraft((current) => ({ ...current, companies: next }));
|
||||
}}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...optionProps } = props;
|
||||
return (
|
||||
<li key={key} {...optionProps}>
|
||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||
{option.name}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.name;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeFilterFields({
|
||||
draft,
|
||||
setDraft,
|
||||
tradeOptions,
|
||||
}: {
|
||||
draft: VendorFilters;
|
||||
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||
tradeOptions: string[];
|
||||
}) {
|
||||
return (
|
||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={tradeOptions}
|
||||
value={draft.trades}
|
||||
onChange={(_event, value) =>
|
||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||
}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationFilterFields({
|
||||
draft,
|
||||
setDraft,
|
||||
locationOptions,
|
||||
}: {
|
||||
draft: VendorFilters;
|
||||
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||
locationOptions: VendorFacetLocation[];
|
||||
}) {
|
||||
return (
|
||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={locationOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.label === value.label
|
||||
}
|
||||
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.label,
|
||||
);
|
||||
setDraft((current) => ({ ...current, locations: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
function JobBucketFilterFields({
|
||||
draft,
|
||||
setDraft,
|
||||
jobBucketOptions,
|
||||
}: {
|
||||
draft: VendorFilters;
|
||||
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||
jobBucketOptions: VendorFacetJobBucket[];
|
||||
}) {
|
||||
return (
|
||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={jobBucketOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.id === value.id
|
||||
}
|
||||
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.id,
|
||||
);
|
||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorFiltersDrawerFields({
|
||||
draft,
|
||||
setDraft,
|
||||
options,
|
||||
}: VendorFiltersDrawerFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<CompanyFilterFields draft={draft} setDraft={setDraft} companyOptions={options.companies} />
|
||||
<TradeFilterFields draft={draft} setDraft={setDraft} tradeOptions={options.trades} />
|
||||
<LocationFilterFields draft={draft} setDraft={setDraft} locationOptions={options.locations} />
|
||||
<JobBucketFilterFields
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
jobBucketOptions={options.jobBuckets}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx
vendored
Normal file
36
src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
export function FilterSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||
{children}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||
import { VendorFilterOptions } from "./vendor-filter-options";
|
||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||
|
||||
interface VendorFiltersDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -25,12 +25,18 @@ export function VendorFiltersDrawer({
|
|||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(appliedFilters);
|
||||
if (open) {
|
||||
setDraft(appliedFilters);
|
||||
}
|
||||
}, [open, appliedFilters]);
|
||||
|
||||
const activeCount =
|
||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||
|
||||
const handleClear = () => {
|
||||
setDraft(emptyVendorFilters);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(draft);
|
||||
onClose();
|
||||
|
|
@ -65,7 +71,7 @@ export function VendorFiltersDrawer({
|
|||
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||
Filters
|
||||
</Typography>
|
||||
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
||||
{Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
|
||||
</Stack>
|
||||
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
|
|
@ -85,12 +91,7 @@ export function VendorFiltersDrawer({
|
|||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setDraft(emptyVendorFilters)}
|
||||
disabled={activeCount === 0}
|
||||
fullWidth
|
||||
>
|
||||
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,37 @@ function PageShell({
|
|||
);
|
||||
}
|
||||
|
||||
function RosterLoadErrorState({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
|
||||
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to vendors
|
||||
</Button>
|
||||
</Stack>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } {
|
||||
if (isEdit) {
|
||||
return {
|
||||
title: "Edit Vendor Company",
|
||||
subtitle: "Update company details and technicians in one save.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Add Vendor Company",
|
||||
subtitle: "Register a company and add zero, one, or multiple technicians.",
|
||||
};
|
||||
}
|
||||
|
||||
function submitLabel(isSaving: boolean, isEdit: boolean): string {
|
||||
if (isSaving) return "Saving...";
|
||||
return isEdit ? "Save" : "Create";
|
||||
}
|
||||
|
||||
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEdit = vendorId !== undefined || companyId !== undefined;
|
||||
|
|
@ -59,26 +90,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
}
|
||||
|
||||
if (isEdit && form.isError) {
|
||||
return (
|
||||
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
|
||||
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/vendors")}>
|
||||
Back to vendors
|
||||
</Button>
|
||||
</Stack>
|
||||
</PageShell>
|
||||
);
|
||||
return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
|
||||
}
|
||||
|
||||
if (isEdit && !form.roster) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
|
||||
const subtitle = isEdit
|
||||
? "Update company details and technicians in one save."
|
||||
: "Register a company and add zero, one, or multiple technicians.";
|
||||
const { title, subtitle } = rosterPageCopy(isEdit);
|
||||
const companySelectionProps = isEdit
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -87,6 +106,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
onSelectCompany: form.selectCompany,
|
||||
onClearSelectedCompany: form.clearSelectedCompany,
|
||||
};
|
||||
const showPortalToken = Boolean(isEdit && vendorId);
|
||||
|
||||
return (
|
||||
<PageShell title={title} subtitle={subtitle}>
|
||||
|
|
@ -107,7 +127,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
{...companySelectionProps}
|
||||
/>
|
||||
|
||||
{isEdit && vendorId !== undefined && vendorId !== "" && (
|
||||
{showPortalToken && vendorId !== undefined && (
|
||||
<VendorPortalTokenPanel vendorId={vendorId} />
|
||||
)}
|
||||
|
||||
|
|
@ -120,7 +140,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
{submitLabel(form.isSaving, isEdit)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useParams } from "react-router";
|
||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||
import { Box } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel";
|
||||
|
|
@ -15,20 +17,17 @@ export default function WorkOrderViewPage() {
|
|||
const workOrderId = id ?? "";
|
||||
const view = useWorkOrderViewPage(workOrderId);
|
||||
|
||||
if (view.isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-12">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
if (view.isLoading && !view.wo) {
|
||||
return <DetailPageSkeleton variant="view" />;
|
||||
}
|
||||
|
||||
if (view.error || !view.wo) {
|
||||
if (!view.wo) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||
</Alert>
|
||||
<QueryErrorPanel
|
||||
message={view.error instanceof Error ? view.error.message : "Work order not found."}
|
||||
onRetry={() => void view.refetch()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function CompDocDialogFooterDraftActions({
|
|||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
disabled={isUploading}
|
||||
onClick={onMarkNotRequired}
|
||||
>
|
||||
<Ban size={14} /> Mark as not required
|
||||
|
|
@ -38,6 +39,7 @@ export function CompDocDialogFooterDraftActions({
|
|||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
disabled={isUploading}
|
||||
onClick={onSaveDraft}
|
||||
>
|
||||
<Save size={14} /> Save draft
|
||||
|
|
@ -47,7 +49,7 @@ export function CompDocDialogFooterDraftActions({
|
|||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
disabled={!rowVersion}
|
||||
disabled={!rowVersion || isUploading}
|
||||
loading={isUploading}
|
||||
loadingText="Uploading…"
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
|
|
@ -60,6 +62,7 @@ export function CompDocDialogFooterDraftActions({
|
|||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
disabled={isUploading}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<Sparkles size={14} /> Generate
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
type CompDocDialogHeaderProps = {
|
||||
info: WorkOrderTableRow;
|
||||
row: WorkOrderTableRow;
|
||||
cfg: CompletionDocStateConfig;
|
||||
isUploading: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||
export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
|
|
@ -45,10 +45,10 @@ export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDoc
|
|||
>
|
||||
WO{" "}
|
||||
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||
#{info.woNumber}
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
{" · "}
|
||||
{serviceTitle(info)}
|
||||
{serviceTitle(row)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
|
||||
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
|
||||
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
||||
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
|
||||
import { CompDocPreview } from "./comp-doc-preview";
|
||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||
import { useCompDocDialog } from "./use-comp-doc-dialog";
|
||||
|
||||
type CompDocDialogProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -20,6 +28,59 @@ type CompDocDialogProps = {
|
|||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type UseCompDocPdfUploadArgs = {
|
||||
workOrderId: string | number;
|
||||
rowVersion: string;
|
||||
onUploaded: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function rejectCompDocPdf(file: File, rowVersion: string): string | null {
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
return `File exceeds the ${formatMediaSizeLimit()} limit.`;
|
||||
}
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
return "Please upload a PDF file.";
|
||||
}
|
||||
if (!rowVersion) {
|
||||
return "Missing work order version. Refresh and try again.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function useCompDocPdfUpload({ workOrderId, rowVersion, onUploaded }: UseCompDocPdfUploadArgs) {
|
||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
|
||||
function handleUploadPdf(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const rejection = rejectCompDocPdf(file, rowVersion);
|
||||
if (rejection) {
|
||||
toast.error(rejection);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadCompletionMutation.mutate(
|
||||
{ file, workOrderVersion: rowVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Keep close/actions disabled across the gap between upload settle and docStatus persist.
|
||||
setIsFinalizing(true);
|
||||
void Promise.resolve(onUploaded()).finally(() => setIsFinalizing(false));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pdfInputRef,
|
||||
handleUploadPdf,
|
||||
isUploading: uploadCompletionMutation.isPending || isFinalizing,
|
||||
};
|
||||
}
|
||||
|
||||
export function CompDocDialog({
|
||||
row,
|
||||
open,
|
||||
|
|
@ -28,14 +89,33 @@ export function CompDocDialog({
|
|||
onSave,
|
||||
onDateCompletedChange,
|
||||
}: CompDocDialogProps) {
|
||||
const dialog = useCompDocDialog({
|
||||
row,
|
||||
open,
|
||||
data,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
|
||||
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||
|
||||
const templateUrl = boardDetail?.completion.template?.templateUrl;
|
||||
const signOffAttachment = boardDetail?.completion.signOffAttachment || null;
|
||||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||
|
||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
||||
const upload = useCompDocPdfUpload({
|
||||
workOrderId: row.id,
|
||||
rowVersion,
|
||||
onUploaded: dialog.handleGenerate,
|
||||
});
|
||||
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
|
||||
|
||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||
const isGenerated = dialog.isGenerated || Boolean(signOffAttachment);
|
||||
const handleClose = upload.isUploading ? undefined : () => onOpenChange(false);
|
||||
|
||||
function handleDownloadPdf() {
|
||||
if (signOffAttachment) {
|
||||
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
dialog.handleDownloadPdf();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -66,45 +146,45 @@ export function CompDocDialog({
|
|||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Completion Document — WO #{dialog.info.woNumber}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||
<Text className="sr-only" variant="description">
|
||||
Generate, save or mark not-required the completion document for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CompDocDialogHeader
|
||||
info={dialog.info}
|
||||
row={row}
|
||||
cfg={dialog.cfg}
|
||||
isUploading={dialog.isUploading}
|
||||
isUploading={upload.isUploading}
|
||||
onClose={() => handleClose?.()}
|
||||
/>
|
||||
|
||||
<CompDocDialogResourceLinks
|
||||
templateUrl={dialog.templateUrl}
|
||||
signOffAttachment={dialog.signOffAttachment}
|
||||
templateUrl={templateUrl}
|
||||
signOffAttachment={signOffAttachment}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<CompDocPreview
|
||||
row={dialog.info}
|
||||
row={row}
|
||||
site={dialog.site}
|
||||
notes={dialog.notes}
|
||||
generatedAt={dialog.generatedAt}
|
||||
isGenerated={dialog.isGenerated}
|
||||
isGenerated={isGenerated}
|
||||
isNotRequired={dialog.isNotRequired}
|
||||
/>
|
||||
<CompDocSidebar
|
||||
row={dialog.info}
|
||||
row={row}
|
||||
site={dialog.site}
|
||||
docState={dialog.docState}
|
||||
generatedAt={dialog.generatedAt}
|
||||
notes={dialog.notes}
|
||||
editAddress={dialog.editAddress}
|
||||
onAddressChange={dialog.setEditAddress}
|
||||
editDateCompleted={dialog.editDateCompleted}
|
||||
amazonRep={dialog.amazonRep}
|
||||
seaRep={dialog.seaRep}
|
||||
isGenerated={dialog.isGenerated}
|
||||
isGenerated={isGenerated}
|
||||
onNotesChange={dialog.setNotes}
|
||||
onDateCompletedChange={dialog.setEditDateCompleted}
|
||||
/>
|
||||
|
|
@ -112,18 +192,18 @@ export function CompDocDialog({
|
|||
|
||||
<CompDocDialogFooter
|
||||
docState={dialog.docState}
|
||||
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
|
||||
rowVersion={dialog.rowVersion}
|
||||
isUploading={dialog.isUploading}
|
||||
signOffAttachment={dialog.signOffAttachment}
|
||||
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||
rowVersion={rowVersion}
|
||||
isUploading={upload.isUploading}
|
||||
signOffAttachment={signOffAttachment}
|
||||
generatedAt={dialog.generatedAt}
|
||||
pdfInputRef={dialog.pdfInputRef}
|
||||
pdfInputRef={upload.pdfInputRef}
|
||||
onMarkNotRequired={dialog.handleMarkNotRequired}
|
||||
onSaveDraft={dialog.handleSaveDraft}
|
||||
onGenerate={dialog.handleGenerate}
|
||||
onUndo={dialog.handleUndo}
|
||||
onDownloadPdf={dialog.handleDownloadPdf}
|
||||
onUploadPdf={dialog.handleUploadPdf}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
onUploadPdf={upload.handleUploadPdf}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
|
||||
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
|
||||
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
|
||||
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
||||
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
||||
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
||||
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
||||
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
|
||||
|
||||
type CompDocPreviewProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -30,7 +32,7 @@ export function CompDocPreview({
|
|||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
|
||||
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
|
|
@ -48,16 +50,38 @@ export function CompDocPreview({
|
|||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewDocument
|
||||
row={row}
|
||||
site={site}
|
||||
notes={notes}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
isNotRequired={isNotRequired}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: isNotRequired ? 0.5 : 1,
|
||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 12,
|
||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0 28px 28px" }}>
|
||||
<CompDocPreviewHeader />
|
||||
<CompDocPreviewSummary
|
||||
row={row}
|
||||
site={site}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 24,
|
||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
|
||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,26 +7,29 @@ type CompDocProcedureListProps = {
|
|||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||
return (
|
||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||
{proc.steps.map((st, i) => (
|
||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{st.text}
|
||||
{st.sub != null && (
|
||||
<ol
|
||||
style={{
|
||||
listStyle: "lower-alpha",
|
||||
paddingLeft: 22,
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{st.sub.map((x, j) => (
|
||||
<li key={j} style={{ margin: "2px 0" }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{proc.steps.map((st, i) => {
|
||||
const subSteps = st.sub ?? [];
|
||||
return (
|
||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{st.text}
|
||||
{subSteps.length > 0 && (
|
||||
<ol
|
||||
style={{
|
||||
listStyle: "lower-alpha",
|
||||
paddingLeft: 22,
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{subSteps.map((x, j) => (
|
||||
<li key={j} style={{ margin: "2px 0" }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStatusCardDescription,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||
import { CompDocEditRow } from "./comp-doc-edit-row";
|
||||
import { CompDocDetailsSection } from "./comp-doc-details-section";
|
||||
import { CompDocNotesField } from "./comp-doc-notes-field";
|
||||
import { CompDocStatusCard } from "./comp-doc-status-card";
|
||||
|
||||
type CompDocSidebarProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -14,6 +10,8 @@ type CompDocSidebarProps = {
|
|||
docState: DocState;
|
||||
generatedAt: string;
|
||||
notes: string;
|
||||
editAddress: string;
|
||||
onAddressChange: (value: string) => void;
|
||||
editDateCompleted: string;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
|
|
@ -28,6 +26,8 @@ export function CompDocSidebar({
|
|||
docState,
|
||||
generatedAt,
|
||||
notes,
|
||||
editAddress,
|
||||
onAddressChange,
|
||||
editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
|
|
@ -35,139 +35,25 @@ export function CompDocSidebar({
|
|||
onNotesChange,
|
||||
onDateCompletedChange,
|
||||
}: CompDocSidebarProps) {
|
||||
const displayState: DocState =
|
||||
isGenerated && docState !== "not-required" ? "generated" : docState;
|
||||
const cfg = DOC_STATE_CFG[displayState];
|
||||
const StateIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
background: cfg.cardBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: cfg.cardIconBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{cfg.cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{docStatusCardDescription(displayState, generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Work Order Details
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||
<CompDocDetailRow
|
||||
icon={MapPin}
|
||||
label="Site Code"
|
||||
value={`${row.site} · Amazon.com Services LLC`}
|
||||
/>
|
||||
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
||||
<CompDocEditRow
|
||||
label="Date Work Completed"
|
||||
value={editDateCompleted}
|
||||
onChange={onDateCompletedChange}
|
||||
type="date"
|
||||
readOnly={isGenerated}
|
||||
/>
|
||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||
<CompDocDetailRow
|
||||
icon={Wrench}
|
||||
label="Seahaven Representative"
|
||||
value={seaRep || "—"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
PM Comments{" "}
|
||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||
— optional, included in the document
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
readOnly={isGenerated}
|
||||
rows={3}
|
||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||
className="mt-2 w-full resize-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
...(isGenerated
|
||||
? {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
|
||||
<CompDocDetailsSection
|
||||
row={row}
|
||||
site={site}
|
||||
editAddress={editAddress}
|
||||
onAddressChange={onAddressChange}
|
||||
editDateCompleted={editDateCompleted}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
amazonRep={amazonRep}
|
||||
seaRep={seaRep}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||
import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -14,14 +14,12 @@ type SlideOverInfoTabDisplayViewProps = {
|
|||
source: WorkOrderTableRow;
|
||||
isCancelled: boolean;
|
||||
dispatcher?: WorkOrderUser;
|
||||
vendors: VendorDropdownItem[];
|
||||
};
|
||||
|
||||
export function SlideOverInfoTabDisplayView({
|
||||
source,
|
||||
isCancelled,
|
||||
dispatcher,
|
||||
vendors,
|
||||
}: SlideOverInfoTabDisplayViewProps) {
|
||||
const appt = parseApptTime(source.apptTime);
|
||||
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
||||
|
|
@ -73,7 +71,7 @@ export function SlideOverInfoTabDisplayView({
|
|||
)}
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Vendor">
|
||||
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
|
||||
<SlideOverInfoTabVendorField source={source} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Status">
|
||||
<SlideOverInfoTabStatusField source={source} />
|
||||
|
|
|
|||
|
|
@ -1,45 +1,25 @@
|
|||
import { useState } from "react";
|
||||
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type SlideOverInfoTabVendorFieldProps = {
|
||||
source: WorkOrderTableRow;
|
||||
vendors: VendorDropdownItem[];
|
||||
};
|
||||
|
||||
export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
|
||||
const [vendorDlg, setVendorDlg] = useState(false);
|
||||
|
||||
/** Display-only vendor summary. Assignment edits belong in edit mode (VendorCell). */
|
||||
export function SlideOverInfoTabVendorField({ source }: SlideOverInfoTabVendorFieldProps) {
|
||||
if (!source.tech && !source.company) {
|
||||
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVendorDlg(true)}
|
||||
className="text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
|
||||
{source.tech || source.company}
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
|
||||
{source.tech || source.company}
|
||||
</div>
|
||||
{Boolean(source.tech && source.company) && (
|
||||
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
|
||||
{source.company}
|
||||
</div>
|
||||
{Boolean(source.tech && source.company) && (
|
||||
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
|
||||
{source.company}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<VendorDialog
|
||||
open={vendorDlg}
|
||||
onOpenChange={setVendorDlg}
|
||||
company={source.company}
|
||||
tech={source.tech}
|
||||
techPhone={source.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={() => setVendorDlg(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export function SlideOverInfoTab({
|
|||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
dispatcher={dispatcher}
|
||||
vendors={vendors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
|
|
@ -56,8 +57,6 @@ export function useSlideOverMediaState({
|
|||
deleteMediaMutation.isPending ||
|
||||
categorizeMediaMutation.isPending;
|
||||
|
||||
const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
|
||||
|
||||
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
||||
startUploads(requests);
|
||||
};
|
||||
|
|
@ -74,7 +73,11 @@ export function useSlideOverMediaState({
|
|||
};
|
||||
|
||||
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||
if (!workOrderVersion) return;
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
};
|
||||
|
||||
|
|
@ -83,7 +86,11 @@ export function useSlideOverMediaState({
|
|||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
if (!workOrderVersion) return;
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = {
|
|||
items: DispatchChecklistItem[];
|
||||
isLocked: boolean;
|
||||
isUpdating: boolean;
|
||||
isAdding?: boolean;
|
||||
newItem: string;
|
||||
onNewItemChange: (value: string) => void;
|
||||
onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
|
||||
|
|
@ -16,6 +17,7 @@ export function DispatchChecklistSection({
|
|||
items,
|
||||
isLocked,
|
||||
isUpdating,
|
||||
isAdding = false,
|
||||
newItem,
|
||||
onNewItemChange,
|
||||
onToggleItem,
|
||||
|
|
@ -58,14 +60,16 @@ export function DispatchChecklistSection({
|
|||
value={newItem}
|
||||
onChange={(event) => onNewItemChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && newItem.trim()) {
|
||||
if (event.key === "Enter" && newItem.trim() && !isAdding) {
|
||||
event.preventDefault();
|
||||
onAddItem(newItem.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="Add checklist item..."
|
||||
placeholder={isAdding ? "Adding…" : "Add checklist item..."}
|
||||
disabled={isAdding}
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Box, Button, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { Box, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { DetailField } from "@/components/common/detail-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DISPATCH_STATUS_OPTIONS,
|
||||
type DispatchDetail,
|
||||
|
|
@ -84,10 +85,12 @@ export function DispatchEditForm({
|
|||
onChange={(event) => onChange({ ...form, description: event.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button variant="contained" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
<Button onClick={onSave} loading={isSaving} loadingText="Saving…">
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
|
@ -138,12 +141,10 @@ export function DispatchDetailsView({
|
|||
)}
|
||||
{!isLocked && (
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
|
||||
<Button variant="destructive" onClick={onCancelDispatch}>
|
||||
Cancel Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button onClick={onEdit}>Edit</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils";
|
|||
type DispatchSignoffsSectionProps = {
|
||||
signoffs: DispatchSignoff[];
|
||||
isLocked: boolean;
|
||||
isSigningOff?: boolean;
|
||||
onCollectSignature: (type: "Customer" | "Vendor") => void;
|
||||
};
|
||||
|
||||
export function DispatchSignoffsSection({
|
||||
signoffs,
|
||||
isLocked,
|
||||
isSigningOff = false,
|
||||
onCollectSignature,
|
||||
}: DispatchSignoffsSectionProps) {
|
||||
return (
|
||||
|
|
@ -49,9 +51,9 @@ export function DispatchSignoffsSection({
|
|||
variant="outlined"
|
||||
className="mt-2"
|
||||
onClick={() => onCollectSignature(type)}
|
||||
disabled={isLocked}
|
||||
disabled={isLocked || isSigningOff}
|
||||
>
|
||||
{isLocked ? "Not signed" : "Collect Signature"}
|
||||
{isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
|
||||
import { Box, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
|
||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DispatchComment } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type DispatchVendorCommunicationProps = {
|
||||
|
|
@ -61,10 +62,11 @@ export function DispatchVendorCommunication({
|
|||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isLocked || isPending || !commentText.trim()}
|
||||
disabled={isLocked || !commentText.trim()}
|
||||
loading={isPending}
|
||||
loadingText="Sending…"
|
||||
>
|
||||
{isPending ? "Sending..." : "Send"}
|
||||
Send
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Alert, Button, Stack, Typography } from "@mui/material";
|
||||
import { Alert, Stack, Typography } from "@mui/material";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type DispatchVerifyFooterProps = {
|
||||
|
|
@ -34,10 +35,15 @@ export function DispatchVerifyFooter({
|
|||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!canVerify || isPending}
|
||||
disabled={!canVerify}
|
||||
loading={isPending}
|
||||
loadingText="Verifying…"
|
||||
onClick={onVerify}
|
||||
sx={{
|
||||
backgroundColor: "success.main",
|
||||
color: "#fff",
|
||||
"&:hover": { backgroundColor: "success.dark" },
|
||||
}}
|
||||
>
|
||||
Verify & Close
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Dialog, DialogTitle } from "@mui/material";
|
||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
|
||||
import { DispatchChecklistSection } from "./dispatch-checklist-section";
|
||||
import { DispatchDetailSkeleton } from "@/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
import { DispatchDetailBody } from "./dispatch-detail-body";
|
||||
import { DispatchDetailHeader } from "./dispatch-detail-header";
|
||||
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
|
||||
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
|
||||
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
|
||||
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
|
||||
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
|
||||
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
|
||||
|
||||
interface DispatchDetailModalProps {
|
||||
|
|
@ -23,13 +20,16 @@ export function DispatchDetailModal({
|
|||
onClose,
|
||||
}: DispatchDetailModalProps) {
|
||||
const controller = useDispatchDetailController(dispatchId, workOrderId);
|
||||
const handleClose = guardDialogClose(controller.isMutating, onClose);
|
||||
const isDecidingUplift =
|
||||
controller.approveUpliftMutation.isPending ||
|
||||
controller.rejectUpliftMutation.isPending ||
|
||||
controller.requestChangesUpliftMutation.isPending;
|
||||
|
||||
if (controller.isLoading) {
|
||||
if (controller.isLoading && !controller.dispatch) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogContent className="flex justify-center py-12">
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
<DispatchDetailSkeleton />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,94 +42,23 @@ export function DispatchDetailModal({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
||||
<Dialog open onClose={handleClose} maxWidth="md" fullWidth scroll="paper">
|
||||
<DialogTitle>
|
||||
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
|
||||
<DispatchDetailHeader
|
||||
dispatch={dispatch}
|
||||
onClose={() => {
|
||||
if (!controller.isMutating) onClose();
|
||||
}}
|
||||
/>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-6">
|
||||
<DispatchDetailsGrid>
|
||||
{controller.editing ? (
|
||||
<DispatchEditForm
|
||||
form={controller.form}
|
||||
onChange={controller.setForm}
|
||||
onSave={controller.handleSave}
|
||||
onCancel={() => controller.setEditing(false)}
|
||||
isSaving={controller.updateDispatchMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<DispatchDetailsView
|
||||
dispatch={dispatch}
|
||||
isLocked={controller.isLocked}
|
||||
onEdit={controller.beginEditing}
|
||||
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
|
||||
/>
|
||||
)}
|
||||
</DispatchDetailsGrid>
|
||||
|
||||
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
|
||||
<DispatchChecklistSection
|
||||
items={dispatch.checklistItems}
|
||||
isLocked={controller.isLocked}
|
||||
isUpdating={controller.updateChecklistMutation.isPending}
|
||||
newItem={controller.newChecklistItem}
|
||||
onNewItemChange={controller.setNewChecklistItem}
|
||||
onToggleItem={(itemId, isCompleted) =>
|
||||
controller.updateChecklistMutation.mutate({
|
||||
itemId,
|
||||
isCompleted,
|
||||
completedBy: controller.userName,
|
||||
})
|
||||
}
|
||||
onAddItem={(text) =>
|
||||
controller.addChecklistMutation.mutate(text, {
|
||||
onSuccess: () => controller.setNewChecklistItem(""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DispatchSignoffsSection
|
||||
signoffs={dispatch.signoffs}
|
||||
isLocked={controller.isLocked}
|
||||
onCollectSignature={controller.setSigCaptureType}
|
||||
/>
|
||||
|
||||
<DispatchUpliftsSection
|
||||
uplifts={controller.uplifts}
|
||||
isLocked={controller.isLocked}
|
||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
||||
onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })}
|
||||
onRequestChanges={(uplift) =>
|
||||
controller.setUpliftAction({ type: "request-changes", uplift })
|
||||
}
|
||||
/>
|
||||
|
||||
<DispatchVendorCommunication
|
||||
comments={dispatch.comments}
|
||||
commentText={controller.commentText}
|
||||
sendEmail={controller.sendEmail}
|
||||
isLocked={controller.isLocked}
|
||||
isPending={controller.addCommentMutation.isPending}
|
||||
onCommentTextChange={controller.setCommentText}
|
||||
onSendEmailChange={controller.setSendEmail}
|
||||
onSubmit={controller.handleComment}
|
||||
/>
|
||||
|
||||
<DispatchVerifyFooter
|
||||
verifiedAt={dispatch.verifiedAt}
|
||||
verifiedBy={dispatch.verifiedBy}
|
||||
canVerify={controller.verifyState.canVerify}
|
||||
missing={controller.verifyState.missing}
|
||||
isPending={controller.verifyMutation.isPending}
|
||||
onVerify={() => controller.verifyMutation.mutate(undefined)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DispatchDetailBody dispatch={dispatch} controller={controller} />
|
||||
</Dialog>
|
||||
|
||||
{controller.sigCaptureType != null && (
|
||||
<SignatureCapture
|
||||
title={`${controller.sigCaptureType} Sign-off`}
|
||||
isPending={controller.addSignoffMutation.isPending}
|
||||
onClose={() => controller.setSigCaptureType(null)}
|
||||
onSave={(signature, method, name) => {
|
||||
controller.addSignoffMutation.mutate(
|
||||
|
|
@ -151,13 +80,13 @@ export function DispatchDetailModal({
|
|||
type={controller.upliftAction.type}
|
||||
requestedNTE={controller.upliftAction.uplift.requestedNTE}
|
||||
currentNTE={controller.upliftAction.uplift.currentNTE}
|
||||
onClose={() => controller.setUpliftAction(null)}
|
||||
onClose={() => {
|
||||
if (!isDecidingUplift) {
|
||||
controller.setUpliftAction(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={controller.handleUpliftConfirm}
|
||||
isPending={
|
||||
controller.approveUpliftMutation.isPending ||
|
||||
controller.rejectUpliftMutation.isPending ||
|
||||
controller.requestChangesUpliftMutation.isPending
|
||||
}
|
||||
isPending={isDecidingUplift}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
|
|
@ -8,6 +7,8 @@ import {
|
|||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
type UpliftDecisionDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -39,6 +40,7 @@ export function UpliftDecisionDialog({
|
|||
const noteRequired = type === "reject" || type === "request-changes";
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return;
|
||||
setNote("");
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -46,11 +48,10 @@ export function UpliftDecisionDialog({
|
|||
const handleConfirm = () => {
|
||||
if (noteRequired && !note.trim()) return;
|
||||
onConfirm(note.trim());
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
|
||||
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||
<DialogContent>
|
||||
{type === "approve" && (
|
||||
|
|
@ -68,14 +69,18 @@ export function UpliftDecisionDialog({
|
|||
required={noteRequired}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={type === "approve" ? "success" : "primary"}
|
||||
disabled={noteRequired ? !note.trim() || isPending : isPending}
|
||||
variant={type === "reject" ? "destructive" : "default"}
|
||||
disabled={noteRequired && !note.trim()}
|
||||
loading={isPending}
|
||||
loadingText="Confirming…"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -20,7 +20,7 @@ export type MediaUploadRequest = {
|
|||
|
||||
type MediaUploaderProps = {
|
||||
uploads: MediaFile[];
|
||||
/** Upload-then-categorize: files only ÔÇö category assigned after persist. */
|
||||
/** Upload-then-categorize: files only — category assigned after persist. */
|
||||
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useRejectUplift,
|
||||
useRequestChangesUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
||||
import { useDispatchUpliftController } from "@/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller";
|
||||
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddChecklistItem,
|
||||
|
|
@ -53,8 +47,8 @@ export function useDispatchDetailController(
|
|||
workOrderId: string | number,
|
||||
) {
|
||||
const { user } = useAuthContext();
|
||||
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
||||
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
||||
const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
|
||||
const uplift = useDispatchUpliftController(dispatchId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<DispatchEditFormState>({
|
||||
|
|
@ -68,10 +62,6 @@ export function useDispatchDetailController(
|
|||
const [commentText, setCommentText] = useState("");
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "reject" | "request-changes";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
|
||||
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
||||
const addCommentMutation = useAddDispatchComment(dispatchId);
|
||||
|
|
@ -79,9 +69,6 @@ export function useDispatchDetailController(
|
|||
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
||||
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const rejectUpliftMutation = useRejectUplift(dispatchId);
|
||||
const requestChangesUpliftMutation = useRequestChangesUplift(dispatchId);
|
||||
|
||||
const userName =
|
||||
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||
|
|
@ -140,30 +127,22 @@ export function useDispatchDetailController(
|
|||
);
|
||||
};
|
||||
|
||||
const handleUpliftConfirm = (note: string) => {
|
||||
if (!upliftAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = upliftAction.uplift.id;
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUpliftMutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!note.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mutation =
|
||||
upliftAction.type === "reject" ? rejectUpliftMutation : requestChangesUpliftMutation;
|
||||
mutation.mutate({ id, note }, { onSuccess: () => setUpliftAction(null) });
|
||||
};
|
||||
const isMutating =
|
||||
updateDispatchMutation.isPending ||
|
||||
addCommentMutation.isPending ||
|
||||
updateChecklistMutation.isPending ||
|
||||
addChecklistMutation.isPending ||
|
||||
addSignoffMutation.isPending ||
|
||||
verifyMutation.isPending ||
|
||||
uplift.isMutating;
|
||||
|
||||
return {
|
||||
dispatch,
|
||||
isLoading,
|
||||
uplifts,
|
||||
isFetching,
|
||||
refetch,
|
||||
uplifts: uplift.uplifts,
|
||||
isLoadingUplifts: uplift.isLoadingUplifts,
|
||||
editing,
|
||||
form,
|
||||
setForm,
|
||||
|
|
@ -175,25 +154,28 @@ export function useDispatchDetailController(
|
|||
setSendEmail,
|
||||
sigCaptureType,
|
||||
setSigCaptureType,
|
||||
upliftAction,
|
||||
setUpliftAction,
|
||||
upliftAction: uplift.upliftAction,
|
||||
setUpliftAction: uplift.setUpliftAction,
|
||||
isLocked,
|
||||
isMutating,
|
||||
userName,
|
||||
verifyState,
|
||||
beginEditing,
|
||||
handleSave,
|
||||
handleCancelDispatch,
|
||||
handleComment,
|
||||
handleUpliftConfirm,
|
||||
handleUpliftConfirm: uplift.handleUpliftConfirm,
|
||||
setEditing,
|
||||
updateDispatchMutation,
|
||||
updateChecklistMutation,
|
||||
addChecklistMutation,
|
||||
addSignoffMutation,
|
||||
verifyMutation,
|
||||
approveUpliftMutation,
|
||||
rejectUpliftMutation,
|
||||
requestChangesUpliftMutation,
|
||||
approveUpliftMutation: uplift.approveUpliftMutation,
|
||||
rejectUpliftMutation: uplift.rejectUpliftMutation,
|
||||
requestChangesUpliftMutation: uplift.requestChangesUpliftMutation,
|
||||
addCommentMutation,
|
||||
};
|
||||
}
|
||||
|
||||
export type DispatchDetailController = ReturnType<typeof useDispatchDetailController>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -19,6 +19,12 @@ type SessionUploadRequest = {
|
|||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
function revokeBlobUrl(url: string | undefined) {
|
||||
if (url?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||
const classified = classifyUpload(request.file);
|
||||
return {
|
||||
|
|
@ -39,10 +45,33 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
|||
return item.id === request.localId && item.signal === request.signal;
|
||||
}
|
||||
|
||||
function revokeBlobUrl(url: string) {
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] {
|
||||
const next = [...previous];
|
||||
for (const entry of entries) {
|
||||
const index = next.findIndex((item) => item.id === entry.id);
|
||||
if (index >= 0) {
|
||||
revokeBlobUrl(next[index]?.url);
|
||||
next[index] = entry;
|
||||
} else {
|
||||
next.push(entry);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropUploads(
|
||||
previous: LocalUpload[],
|
||||
matches: (item: LocalUpload) => boolean,
|
||||
): LocalUpload[] {
|
||||
const kept: LocalUpload[] = [];
|
||||
for (const item of previous) {
|
||||
if (matches(item)) {
|
||||
revokeBlobUrl(item.url);
|
||||
continue;
|
||||
}
|
||||
kept.push(item);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function useMediaUploadSession(
|
||||
|
|
@ -55,7 +84,7 @@ export function useMediaUploadSession(
|
|||
const previousWorkOrderIdRef = useRef(workOrderId);
|
||||
|
||||
// Drop local tiles and abort in-flight uploads when the bound WO changes.
|
||||
// Skip the first bind (undefined  id) so an early upload is not cleared.
|
||||
// Skip the first bind (undefined → id) so an early upload is not cleared.
|
||||
useEffect(() => {
|
||||
if (previousWorkOrderIdRef.current === workOrderId) return;
|
||||
const hadPrevious = previousWorkOrderIdRef.current != null;
|
||||
|
|
@ -66,12 +95,7 @@ export function useMediaUploadSession(
|
|||
controller.abort();
|
||||
}
|
||||
controllersRef.current.clear();
|
||||
setLocalUploads((prev) => {
|
||||
for (const item of prev) {
|
||||
revokeBlobUrl(item.url);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
setLocalUploads((prev) => dropUploads(prev, () => true));
|
||||
}, [workOrderId]);
|
||||
|
||||
const serverUploads = useMemo(
|
||||
|
|
@ -98,6 +122,27 @@ export function useMediaUploadSession(
|
|||
return controller;
|
||||
}
|
||||
|
||||
function settleAttempt(request: SessionUploadRequest) {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request)));
|
||||
}
|
||||
|
||||
function failAttempt(request: SessionUploadRequest, error: Error) {
|
||||
// Keep the controller entry while the attempt is retryable; retry replaces it.
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
? {
|
||||
...item,
|
||||
uploadStatus: "failed",
|
||||
errorMessage: error.message || "Upload failed",
|
||||
progress: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function startUploads(requests: MediaUploadRequest[]) {
|
||||
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
|
||||
localId: request.localId,
|
||||
|
|
@ -105,23 +150,8 @@ export function useMediaUploadSession(
|
|||
signal: replaceController(request.localId).signal,
|
||||
}));
|
||||
|
||||
setLocalUploads((prev) => {
|
||||
const next = [...prev];
|
||||
for (const request of sessionRequests) {
|
||||
const entry = toLocalUpload(request);
|
||||
const index = next.findIndex((item) => item.id === request.localId);
|
||||
if (index >= 0) {
|
||||
const previous = next[index];
|
||||
if (previous) {
|
||||
revokeBlobUrl(previous.url);
|
||||
}
|
||||
next[index] = entry;
|
||||
} else {
|
||||
next.push(entry);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const entries = sessionRequests.map(toLocalUpload);
|
||||
setLocalUploads((prev) => upsertUploads(prev, entries));
|
||||
|
||||
for (const request of sessionRequests) {
|
||||
void uploadMutateAsync({
|
||||
|
|
@ -135,29 +165,13 @@ export function useMediaUploadSession(
|
|||
);
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
})
|
||||
.then(() => settleAttempt(request))
|
||||
.catch((error: Error) => {
|
||||
if (error.name === "AbortError") {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
settleAttempt(request);
|
||||
return;
|
||||
}
|
||||
// Keep controller entry only while retryable; replaceController on retry.
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
? {
|
||||
...item,
|
||||
uploadStatus: "failed",
|
||||
errorMessage: error.message || "Upload failed",
|
||||
progress: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
failAttempt(request, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -174,21 +188,19 @@ export function useMediaUploadSession(
|
|||
]);
|
||||
}
|
||||
|
||||
function cancelUpload(localId: string) {
|
||||
/** Abort any in-flight attempt for the tile and drop it from the session. */
|
||||
function dropLocal(localId: string) {
|
||||
controllersRef.current.get(localId)?.abort();
|
||||
clearController(localId);
|
||||
setLocalUploads((prev) => {
|
||||
const target = prev.find((item) => item.id === localId);
|
||||
if (target) {
|
||||
revokeBlobUrl(target.url);
|
||||
}
|
||||
return prev.filter((item) => item.id !== localId);
|
||||
});
|
||||
setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId));
|
||||
}
|
||||
|
||||
function cancelUpload(localId: string) {
|
||||
dropLocal(localId);
|
||||
}
|
||||
|
||||
function removeLocal(id: string) {
|
||||
clearController(id);
|
||||
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
||||
dropLocal(id);
|
||||
}
|
||||
|
||||
function isLocal(id: string) {
|
||||
|
|
|
|||
114
src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
Normal file
114
src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
91
src/components/common/signature-capture-fields.tsx
Normal file
91
src/components/common/signature-capture-fields.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,119 +1,38 @@
|
|||
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { SignatureCaptureFields } from "@/components/common/signature-capture-fields";
|
||||
import { useSignatureCanvas } from "@/components/common/use-signature-canvas";
|
||||
|
||||
interface SignatureCaptureProps {
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
onSave: (signature: string, method: "drawn" | "typed", name: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) {
|
||||
export function SignatureCapture({
|
||||
onClose,
|
||||
onSave,
|
||||
title,
|
||||
isPending = false,
|
||||
}: SignatureCaptureProps) {
|
||||
const [mode, setMode] = useState<"draw" | "type">("draw");
|
||||
const [typedName, setTypedName] = useState("");
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDrawing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "draw" && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const getPos = (event: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = "touches" in event ? event.touches[0] : event;
|
||||
if (!touch) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
||||
};
|
||||
|
||||
const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
|
||||
event.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawing.current = true;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = getPos(event);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
};
|
||||
|
||||
const draw = (event: React.MouseEvent | React.TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isDrawing.current || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = getPos(event);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const stopDraw = () => {
|
||||
isDrawing.current = false;
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
const { canvasRef, startDraw, draw, stopDraw, clearCanvas } = useSignatureCanvas(mode);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!signerName.trim()) {
|
||||
if (isPending || !signerName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,77 +53,41 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
|
|||
};
|
||||
|
||||
const dialog = (
|
||||
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<Dialog open onClose={guardDialogClose(isPending, onClose)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle className="flex items-center justify-between pr-2">
|
||||
{title || "Capture Signature"}
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small" disabled={isPending}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Full Name"
|
||||
value={signerName}
|
||||
onChange={(event) => setSignerName(event.target.value)}
|
||||
placeholder="Enter signer's full name"
|
||||
fullWidth
|
||||
size="small"
|
||||
<SignatureCaptureFields
|
||||
isPending={isPending}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
signerName={signerName}
|
||||
onSignerNameChange={setSignerName}
|
||||
typedName={typedName}
|
||||
onTypedNameChange={setTypedName}
|
||||
canvasRef={canvasRef}
|
||||
startDraw={startDraw}
|
||||
draw={draw}
|
||||
stopDraw={stopDraw}
|
||||
clearCanvas={clearCanvas}
|
||||
/>
|
||||
|
||||
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => setMode(value)}>
|
||||
<Tab label="Draw" value="draw" />
|
||||
<Tab label="Type" value="type" />
|
||||
</Tabs>
|
||||
|
||||
{mode === "draw" ? (
|
||||
<Box className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={460}
|
||||
height={180}
|
||||
className="block w-full cursor-crosshair rounded border border-border bg-background"
|
||||
onMouseDown={startDraw}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDraw}
|
||||
onMouseLeave={stopDraw}
|
||||
onTouchStart={startDraw}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDraw}
|
||||
/>
|
||||
<Button size="small" onClick={clearCanvas} className="mt-2">
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={typedName}
|
||||
onChange={(event) => setTypedName(event.target.value)}
|
||||
placeholder="Type your name"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
{Boolean(typedName) && (
|
||||
<Typography
|
||||
variant="h5"
|
||||
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
|
||||
>
|
||||
{typedName}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={!signerName.trim() || (mode === "type" && !typedName.trim())}
|
||||
disabled={isPending || !signerName.trim() || (mode === "type" && !typedName.trim())}
|
||||
>
|
||||
Save Signature
|
||||
{isPending ? "Saving…" : "Save Signature"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
85
src/components/common/use-signature-canvas.ts
Normal file
85
src/components/common/use-signature-canvas.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -6,11 +6,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiPost } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
disableAuthBypassForSession,
|
||||
getBypassAuthUser,
|
||||
isAuthBypassEnabled,
|
||||
} from "@/lib/auth/auth-bypass";
|
||||
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
|
||||
import type {
|
||||
AuthUser,
|
||||
|
|
@ -27,18 +22,10 @@ export async function login(credentials: LoginCredentials): Promise<AuthUser> {
|
|||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (isAuthBypassEnabled()) {
|
||||
disableAuthBypassForSession();
|
||||
}
|
||||
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<AuthUser | null> {
|
||||
if (isAuthBypassEnabled()) {
|
||||
return getBypassAuthUser();
|
||||
}
|
||||
|
||||
const user = getAuthUser();
|
||||
|
||||
if (!user || !isSessionValid(user)) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
|||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
|
||||
export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
||||
return useQuery({
|
||||
|
|
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
|||
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: (user) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
toast.success("Login successful!");
|
||||
navigate("/");
|
||||
},
|
||||
|
|
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
return useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
|
||||
stopQueryBroadcast(queryClient);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
stopQueryBroadcast(queryClient);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@ import type {
|
|||
WorkOrderTypeApi,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
LIFECYCLE_STATUS_API,
|
||||
WORK_ORDER_TYPE_API,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WizardWOStatus,
|
||||
WOType,
|
||||
WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
|
|
@ -98,6 +105,10 @@ export function mapWizardDraftToBoardCreatePayload(
|
|||
siteCode,
|
||||
};
|
||||
|
||||
if (draft.status) {
|
||||
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
|
||||
}
|
||||
|
||||
if (draft.woNumber.trim()) {
|
||||
payload.woNumber = draft.woNumber.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { env } from "@/lib/env";
|
||||
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
||||
|
||||
export function isWoWizardMockEnabled(): boolean {
|
||||
return env.isDev;
|
||||
}
|
||||
|
||||
export function shouldUseMockWoWizardCreate(locationId: string): boolean {
|
||||
return isAuthBypassEnabled() || locationId.startsWith("mock-");
|
||||
return env.isDev && locationId.startsWith("mock-");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Cancel
|
|||
export interface WorkOrderBoardCreatePayload {
|
||||
workOrderType: WorkOrderTypeApi;
|
||||
siteCode: string;
|
||||
/** Lifecycle status selected in the wizard; numeric board enum. */
|
||||
lifecycleStatus?: LifecycleStatusApi;
|
||||
woNumber?: string;
|
||||
assignTo?: string;
|
||||
dueDate?: string;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export const env = {
|
||||
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
|
||||
isDev: import.meta.env.DEV,
|
||||
authBypass: import.meta.env.VITE_AUTH_BYPASS === "true",
|
||||
} as const;
|
||||
|
|
|
|||
60
src/lib/query/setup-query-broadcast.ts
Normal file
60
src/lib/query/setup-query-broadcast.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -1,14 +1,26 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
|
||||
import { startQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useSession();
|
||||
const loginMutation = useLogin();
|
||||
const logoutMutation = useLogout();
|
||||
const changePasswordMutation = useChangePassword();
|
||||
|
||||
const userId = sessionQuery.data?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
startQueryBroadcast(queryClient, userId);
|
||||
}, [queryClient, userId]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user: sessionQuery.data,
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ describe("VendorsListPage", () => {
|
|||
|
||||
expect(company).toBeInTheDocument();
|
||||
expect(company).toHaveValue("Draft Company Name");
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("opens inline edit when the table Edit control is clicked", async () => {
|
||||
setupDefaults();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
|
|
@ -81,6 +82,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
|||
beforeEach(() => {
|
||||
onSave.mockReset();
|
||||
onDateCompletedChange.mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
function renderDialog(row: WorkOrderTableRow) {
|
||||
|
|
@ -135,7 +137,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
|||
expect(row.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
|
||||
it("Generate emits completedDate before doc Yes without auto-setting lifecycle Completed", async () => {
|
||||
const row = baseRow({ completedDate: "2026-07-01" });
|
||||
const { patches } = renderDialog(row);
|
||||
|
||||
|
|
@ -156,11 +158,42 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
|||
expect(row.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
|
||||
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
|
||||
const row = baseRow({ completedDate: "2026-07-01" });
|
||||
it("Generate keeps Draft and skips docStatus Yes when Date Work Completed is blank", async () => {
|
||||
const row = baseRow({ completedDate: "" });
|
||||
renderDialog(row);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Date Work Completed is required to generate the document.",
|
||||
);
|
||||
});
|
||||
expect(onDateCompletedChange).not.toHaveBeenCalled();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
|
||||
const row = baseRow({ completedDate: "2026-07-01" });
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
|
||||
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompDocDialog
|
||||
row={row}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
changeDateCompleted("2026-07-20");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
|
|
@ -169,6 +202,9 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
|||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(invalidateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
|
|
@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
|
|||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({
|
||||
data: {
|
||||
info: { status: "Scheduled" },
|
||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
||||
media: [
|
||||
{
|
||||
id: "11",
|
||||
|
|
@ -80,12 +80,24 @@ function baseRow(): WorkOrderTableRow {
|
|||
title: "Emergency",
|
||||
priority: "Sev 1",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
rowVersion: "ROW-VER==",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaUploadModal API wiring", () => {
|
||||
beforeEach(() => {
|
||||
uploadMutateAsync.mockReset();
|
||||
|
|
@ -94,15 +106,7 @@ describe("MediaUploadModal API wiring", () => {
|
|||
});
|
||||
|
||||
it("renders persisted media from board detail (refresh-safe)", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
|
|
@ -111,4 +115,22 @@ describe("MediaUploadModal API wiring", () => {
|
|||
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
|
||||
target: { value: "After" },
|
||||
});
|
||||
|
||||
expect(categorizeMutate).toHaveBeenCalledWith({
|
||||
mediaId: "11",
|
||||
category: "After",
|
||||
workOrderVersion: "DETAIL-VER==",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,23 @@ function makeFile(name = "photo.jpg") {
|
|||
return new File(["bytes"], name, { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
function boardDetail(id: number) {
|
||||
return {
|
||||
info: { id },
|
||||
media: [],
|
||||
comments: [],
|
||||
audit: [],
|
||||
completion: {
|
||||
docStatus: "",
|
||||
template: null,
|
||||
signOffName: "",
|
||||
signOffAttachment: "",
|
||||
signOffSignature: "",
|
||||
dispatchSignoffs: [],
|
||||
},
|
||||
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||
}
|
||||
|
||||
describe("useMediaUploadSession retry / cancel", () => {
|
||||
it("retry upserts the same localId instead of appending a duplicate", async () => {
|
||||
let rejectFirst!: (error: Error) => void;
|
||||
|
|
@ -145,39 +162,9 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const detailA = {
|
||||
info: { id: 1 },
|
||||
media: [],
|
||||
comments: [],
|
||||
audit: [],
|
||||
completion: {
|
||||
docStatus: "",
|
||||
template: null,
|
||||
signOffName: "",
|
||||
signOffAttachment: "",
|
||||
signOffSignature: "",
|
||||
dispatchSignoffs: [],
|
||||
},
|
||||
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||
|
||||
const detailB = {
|
||||
info: { id: 2 },
|
||||
media: [],
|
||||
comments: [],
|
||||
audit: [],
|
||||
completion: {
|
||||
docStatus: "",
|
||||
template: null,
|
||||
signOffName: "",
|
||||
signOffAttachment: "",
|
||||
signOffSignature: "",
|
||||
dispatchSignoffs: [],
|
||||
},
|
||||
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
|
||||
{ initialProps: { detail: detailA } },
|
||||
{ initialProps: { detail: boardDetail(1) } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
|
|
@ -196,7 +183,7 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
|
||||
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||
|
||||
rerender({ detail: detailB });
|
||||
rerender({ detail: boardDetail(2) });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads).toHaveLength(0);
|
||||
|
|
|
|||
62
src/test/components/common/signature-capture.test.tsx
Normal file
62
src/test/components/common/signature-capture.test.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
/**
|
||||
* Mirrors how a consumer wires `isPending` from a mutation: the flag flips to
|
||||
* `true` synchronously once `onSave` fires and only clears when the caller
|
||||
* decides to (e.g. on mutation settle), never inside `SignatureCapture` itself.
|
||||
*/
|
||||
function SignatureCaptureHarness({ onSave }: { onSave: () => void }) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
return (
|
||||
<SignatureCapture
|
||||
title="Customer Sign-off"
|
||||
isPending={isPending}
|
||||
onClose={vi.fn()}
|
||||
onSave={() => {
|
||||
setIsPending(true);
|
||||
onSave();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function fillTypedSignature(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(screen.getByPlaceholderText("Enter signer's full name"), "Jane Doe");
|
||||
await user.click(screen.getByRole("tab", { name: "Type" }));
|
||||
await user.type(screen.getByPlaceholderText("Type your name"), "Jane Doe");
|
||||
}
|
||||
|
||||
describe("SignatureCapture", () => {
|
||||
it("disables Save Signature and closing controls while isPending", () => {
|
||||
renderWithProviders(
|
||||
<SignatureCapture title="Vendor Sign-off" isPending onClose={vi.fn()} onSave={vi.fn()} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onSave exactly once when Save Signature is double-clicked before the mutation settles", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSave = vi.fn();
|
||||
renderWithProviders(<SignatureCaptureHarness onSave={handleSave} />, { withAuth: false });
|
||||
|
||||
await fillTypedSignature(user);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Signature" });
|
||||
fireEvent.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(handleSave).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -119,6 +119,7 @@ describe("workOrdersApi.createFromBoard", () => {
|
|||
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
|
||||
workOrderType: 2,
|
||||
siteCode: "BK5",
|
||||
lifecycleStatus: 1,
|
||||
woNumber: "12345",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,9 +55,25 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
workOrderType: 2,
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
lifecycleStatus: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps wizard status to lifecycleStatus on create", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
status: "Scheduled",
|
||||
});
|
||||
|
||||
expect(payload.lifecycleStatus).toBe(3);
|
||||
});
|
||||
|
||||
it("maps full wizard payload with structured service and notes fields", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
|
|||
109
src/test/lib/query/setup-query-broadcast.test.ts
Normal file
109
src/test/lib/query/setup-query-broadcast.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const disposeA = vi.fn();
|
||||
const disposeB = vi.fn();
|
||||
const broadcastQueryClient = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
|
||||
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
|
||||
}));
|
||||
|
||||
describe("query broadcast account isolation", () => {
|
||||
beforeEach(() => {
|
||||
broadcastQueryClient.mockReset();
|
||||
disposeA.mockReset();
|
||||
disposeB.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not call broadcastQueryClient when MODE is test", async () => {
|
||||
vi.stubEnv("MODE", "test");
|
||||
vi.resetModules();
|
||||
|
||||
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
|
||||
const client = new QueryClient();
|
||||
|
||||
startQueryBroadcast(client, "user-a");
|
||||
|
||||
expect(broadcastQueryClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes the BroadcastChannel to the authenticated user id", async () => {
|
||||
vi.stubEnv("MODE", "development");
|
||||
vi.resetModules();
|
||||
broadcastQueryClient.mockReturnValue(disposeA);
|
||||
|
||||
const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
|
||||
await import("@/lib/query/setup-query-broadcast");
|
||||
const client = new QueryClient();
|
||||
|
||||
startQueryBroadcast(client, 42);
|
||||
|
||||
expect(QUERY_BROADCAST_CHANNEL_PREFIX).toBe("seahaven-admin-query");
|
||||
expect(queryBroadcastChannelForUser(42)).toBe("seahaven-admin-query:42");
|
||||
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastQueryClient).toHaveBeenCalledWith({
|
||||
queryClient: client,
|
||||
broadcastChannel: "seahaven-admin-query:42",
|
||||
});
|
||||
});
|
||||
|
||||
it("disposes the prior broadcaster, clears cache, and switches channel across accounts", async () => {
|
||||
vi.stubEnv("MODE", "development");
|
||||
vi.resetModules();
|
||||
broadcastQueryClient.mockReturnValueOnce(disposeA).mockReturnValueOnce(disposeB);
|
||||
|
||||
const { startQueryBroadcast, stopQueryBroadcast } =
|
||||
await import("@/lib/query/setup-query-broadcast");
|
||||
const client = new QueryClient();
|
||||
const clearSpy = vi.spyOn(client, "clear");
|
||||
|
||||
client.setQueryData(["work-orders", "sensitive"], { from: "user-a" });
|
||||
|
||||
startQueryBroadcast(client, "user-a");
|
||||
expect(broadcastQueryClient).toHaveBeenCalledWith({
|
||||
queryClient: client,
|
||||
broadcastChannel: "seahaven-admin-query:user-a",
|
||||
});
|
||||
|
||||
startQueryBroadcast(client, "user-b");
|
||||
|
||||
expect(disposeA).toHaveBeenCalledTimes(1);
|
||||
expect(clearSpy).toHaveBeenCalledTimes(1);
|
||||
expect(client.getQueryData(["work-orders", "sensitive"])).toBeUndefined();
|
||||
expect(broadcastQueryClient).toHaveBeenCalledTimes(2);
|
||||
expect(broadcastQueryClient).toHaveBeenLastCalledWith({
|
||||
queryClient: client,
|
||||
broadcastChannel: "seahaven-admin-query:user-b",
|
||||
});
|
||||
|
||||
client.setQueryData(["vendors", "roster"], { from: "user-b" });
|
||||
stopQueryBroadcast(client);
|
||||
|
||||
expect(disposeB).toHaveBeenCalledTimes(1);
|
||||
expect(clearSpy).toHaveBeenCalledTimes(2);
|
||||
expect(client.getQueryData(["vendors", "roster"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is a no-op when start is called again for the same user", async () => {
|
||||
vi.stubEnv("MODE", "development");
|
||||
vi.resetModules();
|
||||
broadcastQueryClient.mockReturnValue(disposeA);
|
||||
|
||||
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
|
||||
const client = new QueryClient();
|
||||
const clearSpy = vi.spyOn(client, "clear");
|
||||
|
||||
startQueryBroadcast(client, "user-a");
|
||||
startQueryBroadcast(client, "user-a");
|
||||
|
||||
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
|
||||
expect(disposeA).not.toHaveBeenCalled();
|
||||
expect(clearSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,44 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function localStorageIsUsable(): boolean {
|
||||
try {
|
||||
const probe = "__vitest_ls_probe__";
|
||||
globalThis.localStorage.setItem(probe, "1");
|
||||
const ok = globalThis.localStorage.getItem(probe) === "1";
|
||||
globalThis.localStorage.removeItem(probe);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Node 25+ / some harnesses lack a stable localStorage — provide an in-memory mock when needed.
|
||||
if (!localStorageIsUsable()) {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
|
|
|
|||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
|
|
@ -3,8 +3,6 @@
|
|||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_TARGET: string;
|
||||
readonly VITE_AUTH_BYPASS?: string;
|
||||
readonly VITE_WO_TABLE_MOCK?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
## Jira
|
||||
|
||||
- [SH-189](https://luby-us.atlassian.net/browse/SH-189)
|
||||
|
||||
## QA residual
|
||||
|
||||
- P0 done: duplicate dialog also on **wizard create** (not only table cell).
|
||||
- P1 polish: Cancel / View Existing copy tweaks.
|
||||
|
||||
## Summary
|
||||
|
||||
- Show a confirmation dialog when inline WO number editing **or wizard create** detects a duplicate instead of navigating immediately to the existing work order.
|
||||
- Dialog title: **Duplicate Work Order Number**; actions: **View existing** (opens the existing WO via slide-over route) and **Dismiss** (closes the dialog and keeps the current row editable).
|
||||
- Duplicate scan logic shared via `find-duplicate-wo-number-row`.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Edit a WO number in the table to match an existing row → dialog opens with the duplicate number in the message.
|
||||
- [ ] Click **Dismiss** → dialog closes; edited row keeps its original number.
|
||||
- [ ] Repeat duplicate entry → click **View existing** → navigates to `/workorders/{id}` for the existing row.
|
||||
- [ ] Wizard create with duplicate WO number → same dialog; **View existing** closes wizard and opens `/workorders/{id}`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Server-side uniqueness (422) handling
|
||||
- SH provisional numbering
|
||||
Loading…
Add table
Reference in a new issue