Compare commits

...

58 commits

Author SHA1 Message Date
Alexandre Brandizzi
a71407b426
Merge branch 'dev' into feat/sh-250-fe-additive-add 2026-08-19 10:28:04 -03:00
Codex Review Integration
1305bf35dd fix(vendors): bypass the query cache when reloading after a conflict (SH-250)
The reload fix re-seeded the form from queryClient.fetchQuery, which inherits the
global 5-minute staleTime. A 409 never invalidates that key -
useSaveVendorCompanyRoster invalidates only in onSuccess and its onError returns
early for conflicts - and the Add flow has no mounted observer on it, so reload
returned the cached roster with the same stale rowVersion and every retry 409'd
again. Update mode was unaffected because it recovers via query.refetch(), which
bypasses staleTime.

Pass staleTime: 0 on that fetch, since its sole purpose is the freshest
rowVersion. The regression test uses a client with the app's real staleTime; the
default test client uses 0, which masked this entirely.
2026-08-19 10:26:28 -03:00
Arthur Bassi
a62d66a2d1
Merge pull request #109 from Sea-Haven-Industries/feature/wo-advanced-filters-parity
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): advanced filter facets for severity and uplift (SH-121/SH-196)
2026-08-19 09:40:14 -03:00
Arthur Bassi
d534e92d03
Merge branch 'dev' into feature/wo-advanced-filters-parity 2026-08-19 09:33:07 -03:00
Arthur Bassi
88b04a2481
Merge pull request #104 from Sea-Haven-Industries/feature/wo-uplifts-column-and-tab
feat(work-orders): uplift column and dialog with admin revoke (SH-196)
2026-08-19 09:05:26 -03:00
arthur.bassi
9801d6a50e chore(work-orders): merge origin/dev and keep both search date windows
Keep SH-121 all-weeks constants and the SH-165 unassigned Custom range.
2026-08-18 22:13:51 -03:00
arthur.bassi
93df28335f fix(work-orders): keep SH-196 uplift mapping typed after merging origin/dev 2026-08-18 21:44:55 -03:00
arthur.bassi
fe86d46bec Merge remote-tracking branch 'origin/dev' into feature/wo-uplifts-column-and-tab
# Conflicts:
#	src/domain/work-orders/mappers/work-order-table-row-mapper.ts
#	src/domain/work-orders/types/work-order-table-row.ts
2026-08-18 21:42:53 -03:00
Arthur Bassi
b3180ef886
Merge pull request #111 from Sea-Haven-Industries/feat/SH-165-unscheduled-pin
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): align Unscheduled board pin with design (SH-165)
2026-08-18 21:04:51 -03:00
arthur.bassi
07c8c72ee6 Merge remote-tracking branch 'origin/dev' into feat/SH-165-unscheduled-pin 2026-08-18 20:53:13 -03:00
Arthur Bassi
08a32b71dd
Merge pull request #105 from Sea-Haven-Industries/feature/wo-uplift-pending-close-gate
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(work-orders): unify closability and block Completed on pending uplift (SH-188)
2026-08-18 18:37:41 -03:00
arthur.bassi
2554e40fd8 test(work-orders): assert all-weeks total and Clear All restores the bar 2026-08-18 18:13:43 -03:00
Alexandre Brandizzi
2c10d2b7cb
Merge branch 'dev' into feature/wo-advanced-filters-parity 2026-08-18 17:46:53 -03:00
arthur.bassi
b0b94e8a53 merge origin/feat/SH-165-unscheduled-pin 2026-08-18 17:44:39 -03:00
arthur.bassi
bea2858c81 fix(work-orders): search all weeks when advanced filters are applied 2026-08-18 17:40:47 -03:00
arthur.bassi
358495cfe9 fix(work-orders): distinguish unassigned loading from empty pin 2026-08-18 17:40:16 -03:00
arthur.bassi
a109a68f40 fix(work-orders): keep SH-188 tooltip when mixed with other gaps
Stop copying WO lifecycle status into uplift primaryStatus on flat board rows.
2026-08-18 17:28:14 -03:00
Codex Review Integration
ac9ce34101 fix(work-orders): distinguish loading from empty in Unassigned pin (SH-165)
The pinned Unassigned section folded the queue query's pending state into
isFetching only, which the section never read. On first mount, once GET /board
resolved while the queue was still paging GET /board/search, the pin announced
"No unassigned work orders." instead of a loading state.

Expose the queue query's own isLoading as unassignedLoading, thread it through
the table data hook, and render an aria-busy loading row while it is true so the
loading and empty states are separately observable.
2026-08-18 17:22:32 -03:00
arthur.bassi
090130a2c0 chore(work-orders): merge origin/dev into uplifts PR
Keep upliftSummary fixtures and add isAddOn after the Add-On indicator landed on dev.
2026-08-18 13:24:21 -03:00
arthur.bassi
dc9e146080 merge origin/dev into feat/SH-165-unscheduled-pin
Keep SH-165 non-terminal lifecycle statuses next to the Add-On type mapping from dev.
2026-08-18 13:19:44 -03:00
arthur.bassi
32177e831b fix(work-orders): set isAddOn on pending-uplift StatusCell fixture
Keep the SH-188 fixture assignable to WorkOrderTableRow after the SH-184 merge.
2026-08-18 12:04:55 -03:00
arthur.bassi
bf1def809b chore(work-orders): merge origin/dev into SH-188 closability gate
Keep PastDueStatusCell extraction and SH-185 Schedule On copy from merged #102.
2026-08-18 12:01:23 -03:00
arthur.bassi
0966962d8f fix(work-orders): keep Unassigned pin visible when empty
Render the weekly Unassigned section at count 0 and cover assignment through the board PATCH path.
2026-08-18 11:50:46 -03:00
Arthur Bassi
7902476e2a test(work-orders): cover remaining SH-121 facets and document BE producer on dev 2026-08-18 10:27:45 -03:00
arthur.bassi
b60d0474bc fix(work-orders): keep admin-approved revoke off the WO dialog (SH-214)
SH-214/SH-212 own that action on Uplift Approvals; the WO surface only revokes the requester's auto-approved uplift.
2026-08-18 09:28:49 -03:00
arthur.bassi
958467fe40 fix(work-orders): load SH-165 Unassigned queue from global board search 2026-08-17 14:57:39 -03:00
arthur.bassi
e1667702e1 chore(work-orders): merge origin/dev into SH-165 Unassigned pin 2026-08-17 14:39:19 -03:00
Arthur Bassi
260f57ff23 chore(work-orders): merge origin/dev into SH-121 advanced filters 2026-08-17 14:21:09 -03:00
arthur.bassi
37a6098ecb fix(work-orders): require company not technician for Completed (SH-183) 2026-08-17 13:54:05 -03:00
arthur.bassi
aaba7ca2ea Merge remote-tracking branch 'origin/dev' into feature/wo-uplift-pending-close-gate 2026-08-17 13:50:22 -03:00
arthur.bassi
0941a1aa3f chore(work-orders): merge origin/dev and keep SH-196 uplift column 2026-08-17 13:28:52 -03:00
Arthur Bassi
5d7ac297e4 fix(work-orders): drop vendor string from Completed closability (SH-114) 2026-08-14 10:55:06 -03:00
Arthur Bassi
3c914f4e57 Merge remote-tracking branch 'origin/dev' into feature/wo-uplift-pending-close-gate 2026-08-14 10:51:28 -03:00
arthur.bassi
c721b407b6 fix(work-orders): merge dev and gate uplift create on pending and allowance
Hide a second create while a pending request exists, surface remaining auto-approval in the dialog, and refresh the branch onto current origin/dev.
2026-08-14 10:36:45 -03:00
arthur.bassi
65e2741fd0 merge(dev): refresh SH-196 branch onto current origin/dev 2026-08-14 10:29:12 -03:00
arthur.bassi
93034f5da2 fix(work-orders): implement SH-165 Unassigned pinned queue
Replace Unscheduled-focused changes with assignment-based board partition,
accessible disclosure, duplicate prevention, and inline unassign support.
2026-08-13 16:49:50 -03:00
Arthur Bassi
8e61f97200 Merge remote-tracking branch 'origin/dev' into feature/wo-advanced-filters-parity 2026-08-13 16:18:51 -03:00
Arthur Bassi
1835a5939a fix(work-orders): dedupe completedDate and gate Completed on technician (SH-188)
Refresh onto current dev and resolve exact-head review blockers:

- Remove duplicate WorkOrderBoardRowApi.completedDate after merge with dev

- Add SH-114 Technician closability gate alongside company/vendor check

- Update wo-closability regressions for missing technician
2026-08-13 15:21:16 -03:00
Arthur Bassi
43cee3698f merge(dev): refresh SH-188 branch for review fixes 2026-08-13 15:06:33 -03:00
arthur.bassi
4a07d5ca0c chore(work-orders): merge dev and align SH-196 uplift contract
Merge latest dev, remove unused uplift mock store, document landed BE contract, and extract list page panels for maintainability gate.
2026-08-13 14:49:57 -03:00
arthur.bassi
a8c6a629ca merge(dev): resolve SH-196 uplift conflicts with completed date column
Keep UPLIFT after COMP DOC alongside COMPLETED DATE from dev, wire
uplifts through useWorkOrdersListPage, and refresh table-row fixtures.
2026-08-12 17:29:22 -03:00
Arthur Bassi
91422e44ba merge(dev): refresh SH-188 branch onto current origin/dev
Resolve completedDate conflicts keeping required string typing from dev and broader CompletedDate/completedOn aliases for the closability gate.
2026-08-12 11:56:17 -03:00
Arthur Bassi
8a221c8819 fix(work-orders): consume board pendingUpliftCount for SH-188 gate
Map the authoritative board/search/detail pendingUpliftCount into the
Completed closability gate and align the pending-uplift tooltip with SH-188.
2026-08-12 09:55:34 -03:00
Arthur Bassi
ba4f3ca96d fix(work-orders): avoid Array.at in advanced search wiring test
Keep the public-interface assertion compatible with the project's TypeScript lib target.
2026-08-11 15:59:33 -03:00
arthur.bassi
f58541d06a fix(work-orders): keep SH-196 slice under maintainability gates
Extract EmptyInlineCell so WoTableInlineRowCells stays under the 150-line
limit, and add upliftSummary to the unscheduled highlight fixture.
2026-08-11 15:47:23 -03:00
Arthur Bassi
ea747af854 fix(work-orders): isolate advanced filter facets from vendor collateral
Restack SH-121/SH-196 severity and uplift facets on current dev without unrelated Vendor screen changes, and cover public-interface apply plus search totalCount semantics.
2026-08-11 15:44:34 -03:00
arthur.bassi
dd60b1d72e fix(work-orders): allow comp-doc generate without existing completion document
canGenerateCompDoc now ignores the completion-document closability gap so PM/Overdue drafts can enter generation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:28:00 -03:00
arthur.bassi
67c4ee8119 fix(work-orders): only block Completed on evidenced pending uplift
Absence of optional uplift fields means no pending, per SH-188.
2026-08-11 15:28:00 -03:00
arthur.bassi
a3e9a20477 fix(work-orders): fail closed on unknown uplift status 2026-08-11 15:28:00 -03:00
arthur.bassi
ccfb3d9e6d fix(work-orders): split status cell to satisfy max-lines-per-function 2026-08-11 15:27:59 -03:00
arthur.bassi
f6bc50dddd merge(dev): restack SH-196 uplift slice on latest dev 2026-08-11 15:27:57 -03:00
arthur.bassi
6b415f732c fix(work-orders): unify closability with sh-114 and pending uplift 2026-08-11 15:26:21 -03:00
arthur.bassi
f5414e4b14 feat(work-orders): block completed when uplift is pending 2026-08-11 15:26:21 -03:00
arthur.bassi
cd5e164657 fix(work-orders): drop vendor collateral from SH-196 slice
Restore vendors, e2e, and page-header to origin/dev so this PR stays an uplift-only vertical slice and clears the VendorFiltersDrawer maintainability gate.
2026-08-11 15:25:46 -03:00
arthur.bassi
d5fd6aa5d6 fix(work-orders): stop uplift column reading WO status; strip blank techs
mapUpliftSummary no longer treats row status as uplift primaryStatus, getUpliftCellLabel hides pills without hasUplift, and vendor submit filters blank new technicians.
2026-08-10 19:09:25 -03:00
arthur.bassi
f4cfafd7a6 fix(work-orders): single Unscheduled disclosure control for a11y
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 19:07:11 -03:00
Arthur Bassi
2ed770bc79 fix(work-orders): separate Unscheduled header collapse and Add WO controls
[recover] remove malicious eslint payload (was ccec7322)
2026-08-10 17:12:13 -03:00
Arthur Bassi
31c5559d23 fix(work-orders): clear uplift form state only after successful mutations
[recover] remove malicious eslint payload (was 380188af)
2026-08-10 17:12:10 -03:00
85 changed files with 4619 additions and 557 deletions

View file

@ -57,6 +57,50 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
| `severities` | `severities` | Integers `1`–`5` (Emergency/Reactive) |
| `rescheduled` | `rescheduled` | `true` = reschedule count ≥ 2 |
| `carriedOver` | `carriedOver` | `true` = carried-over count ≥ 2 |
| `addOn` | `addOn` | `true` = `isAddOn` indicator |
| `flagColors` | `flagColors` | Multi `#RRGGBB` (board flag palette) |
| `internalOnly` | `internalOnly` | `true` = WO# starts with `SH` |
| `hasUplift` | `hasUplift` | `true` = WO has standing uplift |
| `upliftStatuses` | `upliftStatuses` | With `hasUplift`; see below |
### `severities`
Multi-select SEV 1–5. Applies to Emergency/Reactive work orders with a transcribed APM severity.
### Indicators (`rescheduled`, `carriedOver`, `addOn`)
Boolean flags matching board badge thresholds (counters ≥ 2 for reschedule/carried-over). `addOn` filters the `isAddOn` indicator (not a WO type).
### `flagColors`
Repeated `#RRGGBB` values from the fixed board flag palette (`WorkOrderFlagColors`).
### `internalOnly`
When `true`, only work orders whose number starts with `SH` (internal placeholder).
### Uplift filters
| FE state | API param | Regra |
| ---------------- | ---------------- | ------------------------------------------------ |
| `hasUplift` | `hasUplift` | `true` = WO has non-cancelled/non-revoked uplift |
| `upliftStatuses` | `upliftStatuses` | Optional refinement when `hasUplift=true` |
Allowed `upliftStatuses`: `pending`, `approved`, `auto_approved`, `rejected` (excludes cancelled/revoked).
Exemplo: pending uplifts only — `hasUplift=true&upliftStatuses=pending`.
## Backend producer (`shoc-backend` `dev`)
These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/dev` ([#45](https://github.com/Sea-Haven-Industries/shoc-backend/pull/45), [#65](https://github.com/Sea-Haven-Industries/shoc-backend/pull/65)). There is no frontend deployment fence.
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
- Pagination matches this contract: `page` is 0-based; `pageSize` default/max is 100.
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
### `datePreset`

View file

@ -38,6 +38,11 @@ export const API_PATHS = {
mediaItem: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/revoke`,
lookupsDispatchers: "workorders/lookups/dispatchers",
},
pmSchedule: {

View file

@ -87,6 +87,13 @@ export function useVendorRosterSelection({
queryClient.fetchQuery({
queryKey: queryKeys.vendors.roster("companyId", id),
queryFn: () => vendorCompanyRosterApi.get({ companyId: id }),
// SH-250: this fetch exists to obtain the freshest rowVersion, so it must not
// serve the global 5-minute staleTime cache. A conflict never invalidates this
// key (useSaveVendorCompanyRoster invalidates only on success, and its onError
// returns early for conflicts), and the Add flow has no mounted observer on it.
// Without staleTime: 0 the Reload button re-seeds the same stale rowVersion and
// the retry 409s again indefinitely.
staleTime: 0,
}),
[queryClient],
);

View file

@ -0,0 +1,50 @@
import type { Dispatch, SetStateAction } from "react";
import { CalendarDays } from "lucide-react";
import { AdvFilterPill } from "./adv-filter-pill";
import { AdvSection } from "./adv-section";
import { DateField } from "@/components/ui/date-input";
import {
DATE_RANGE_OPTIONS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
type AdvDateRangeSectionProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
};
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
return (
<AdvSection
icon={CalendarDays}
label="Date range"
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
>
<div className="flex flex-wrap gap-1.5">
{DATE_RANGE_OPTIONS.map((option) => (
<AdvFilterPill
key={option.key}
label={option.label}
selected={draft.dateRange === option.key}
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
</div>
{draft.dateRange === "custom" && (
<div className="mt-2.5 flex items-center gap-2">
<DateField
label="From"
value={draft.customFrom}
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
/>
<DateField
label="To"
value={draft.customTo}
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
/>
</div>
)}
</AdvSection>
);
}

View file

@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from "react";
import {
CalendarDays,
AlertTriangle,
CheckCircle2,
FileText,
Flame,
@ -10,24 +10,24 @@ import {
UserX,
Users,
} from "lucide-react";
import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill";
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
import { AdvDateRangeSection } from "./adv-date-range-section";
import { AdvMultiSelect } from "./adv-multi-select";
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
import { AdvSection } from "./adv-section";
import { AdvSeverityList } from "./adv-severity-list";
import { AdvTypeGrid } from "./adv-type-grid";
import { DateField } from "@/components/ui/date-input";
import { SelectAllAction } from "@/components/ui/select-all-action";
import {
ALL_WIZARD_STATUSES,
ASSIGNEE_FILTER_UNASSIGNED,
COMPLETION_DOC_OPTIONS,
DATE_RANGE_OPTIONS,
DOC_FILTER_LABELS,
type AdvancedFilterSetKey,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
type AdvancedSearchFilterSectionsProps = {
draft: WorkOrderAdvancedFilters;
@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4">
<AdvSection
icon={CalendarDays}
label="Date range"
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
>
<div className="flex flex-wrap gap-1.5">
{DATE_RANGE_OPTIONS.map((option) => (
<AdvFilterPill
key={option.key}
label={option.label}
selected={draft.dateRange === option.key}
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
</div>
{draft.dateRange === "custom" && (
<div className="mt-2.5 flex items-center gap-2">
<DateField
label="From"
value={draft.customFrom}
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
/>
<DateField
label="To"
value={draft.customTo}
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
/>
</div>
)}
</AdvSection>
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
<AdvMultiSelect
@ -113,6 +84,10 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={AlertTriangle} label="Severity" count={draft.severities.size}>
<AdvSeverityList draft={draft} setDraft={setDraft} />
</AdvSection>
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
<AdvMultiSelect
options={[
@ -149,6 +124,13 @@ export function AdvancedSearchFilterSections({
</div>
</AdvSection>
<AdvParityFilterSections
draft={draft}
setDraft={setDraft}
toggleIn={toggleIn}
setMany={setMany}
/>
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}

View file

@ -0,0 +1,146 @@
import type { Dispatch, SetStateAction } from "react";
import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react";
import { Checkbox } from "@mui/material";
import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill";
import { AdvMultiSelect } from "./adv-multi-select";
import { AdvSection } from "./adv-section";
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
import {
type AdvancedFilterSetKey,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import {
UPLIFT_FILTER_LABELS,
UPLIFT_FILTER_STATUSES,
type UpliftFilterStatus,
} from "@/domain/work-orders/types/work-order-uplift-filter";
type AdvParityFilterSectionsProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
};
function indicatorCount(draft: WorkOrderAdvancedFilters): number {
return (draft.rescheduled ? 1 : 0) + (draft.carriedOver ? 1 : 0) + (draft.addOn ? 1 : 0);
}
function FlagColorDot({ value }: { value: string }) {
return (
<span
className="size-3 shrink-0 rounded-full"
style={{ background: value, border: "1px solid var(--color-border)" }}
/>
);
}
export function AdvParityFilterSections({
draft,
setDraft,
toggleIn,
setMany,
}: AdvParityFilterSectionsProps) {
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
setDraft((prev) => {
const next = new Set(prev.upliftStatuses);
if (next.has(status)) {
next.delete(status);
} else {
next.add(status);
}
return { ...prev, upliftStatuses: next };
});
};
return (
<>
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
<div className="flex flex-wrap gap-1.5">
<AdvFilterPill
label="Rescheduled ≥ 2"
selected={draft.rescheduled}
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
className="inline-flex items-center gap-1.5"
/>
<AdvFilterPill
label="Carried Over ≥ 2"
selected={draft.carriedOver}
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
className="inline-flex items-center gap-1.5"
/>
<AdvFilterPill
label="Add-On"
selected={draft.addOn}
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
className="inline-flex items-center gap-1.5"
/>
</div>
</AdvSection>
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
<AdvMultiSelect
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}
selected={draft.flagColors}
onToggle={(value) => toggleIn("flagColors", value)}
onBulkToggle={(values, select) => setMany("flagColors", values, select)}
placeholder="Search flag colour…"
renderLeft={(value) => <FlagColorDot value={value} />}
/>
</AdvSection>
<AdvSection icon={Hash} label="Work Order Number" count={draft.internalOnly ? 1 : 0}>
<button
type="button"
onClick={() => setDraft((prev) => ({ ...prev, internalOnly: !prev.internalOnly }))}
className="flex w-full items-center gap-2 text-left"
>
<span
className="flex size-[18px] shrink-0 items-center justify-center rounded border"
style={{
borderColor: draft.internalOnly ? "var(--color-primary)" : "var(--color-border)",
background: draft.internalOnly ? "var(--color-primary)" : "var(--color-bg-surface)",
}}
>
{Boolean(draft.internalOnly) && <Check size={12} className="text-white" />}
</span>
<span className="text-sm text-muted-foreground">Internal numbers only (SH prefix)</span>
</button>
</AdvSection>
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
<label className="flex cursor-pointer items-center gap-1.5 select-none">
<Checkbox
size="small"
checked={draft.hasUplift}
onChange={() =>
setDraft((prev) => ({
...prev,
hasUplift: !prev.hasUplift,
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
}))
}
sx={{
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--color-primary)" },
}}
/>
<span className="text-sm text-muted-foreground">Has uplift</span>
</label>
{Boolean(draft.hasUplift) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{UPLIFT_FILTER_STATUSES.map((status) => (
<AdvDocPill
key={status}
label={UPLIFT_FILTER_LABELS[status]}
selected={draft.upliftStatuses.has(status)}
onClick={() => toggleUpliftStatus(status)}
/>
))}
</div>
)}
</AdvSection>
</>
);
}

View file

@ -0,0 +1,84 @@
import type { Dispatch, SetStateAction } from "react";
import { Check } from "lucide-react";
import { SelectAllAction } from "@/components/ui/select-all-action";
import {
SEVERITY_LEVELS,
SEVERITY_TOKEN_COLOR,
WORK_ORDER_SEVERITIES,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
type AdvSeverityListProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
};
export function AdvSeverityList({ draft, setDraft }: AdvSeverityListProps) {
const toggleLevel = (level: WorkOrderSeverity) => {
setDraft((prev) => {
const next = new Set(prev.severities);
if (next.has(level)) {
next.delete(level);
} else {
next.add(level);
}
return { ...prev, severities: next };
});
};
return (
<>
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={draft.severities.size > 0}
onClick={() =>
setDraft((prev) => ({
...prev,
severities:
prev.severities.size > 0
? new Set()
: new Set<WorkOrderSeverity>(WORK_ORDER_SEVERITIES),
}))
}
/>
</div>
<div className="grid gap-1.5">
{WORK_ORDER_SEVERITIES.map((level) => {
const meta = SEVERITY_LEVELS[level];
const color = SEVERITY_TOKEN_COLOR[meta.token];
const selected = draft.severities.has(level);
return (
<button
key={level}
type="button"
onClick={() => toggleLevel(level)}
className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-colors"
style={{
borderColor: selected ? color : "var(--color-border)",
background: selected ? "var(--color-bg-muted)" : "transparent",
}}
>
<span
className="flex size-4 shrink-0 items-center justify-center rounded"
style={{
border: `1px solid ${selected ? color : "var(--color-border)"}`,
background: selected ? color : "transparent",
}}
>
{Boolean(selected) && <Check size={11} className="text-white" />}
</span>
<span
className="shrink-0 rounded border px-1.5 py-px text-[11px] font-semibold whitespace-nowrap"
style={{ color, borderColor: color }}
>
{meta.label}
</span>
<span className="flex-1 text-xs text-foreground">{meta.description}</span>
</button>
);
})}
</div>
</>
);
}

View file

@ -14,6 +14,7 @@ type DispatcherFilterProps = {
users: WorkOrderUser[];
usersLoading?: boolean;
currentUserId?: string | number;
disabled?: boolean;
};
function userInitials(name: string): string {
@ -40,6 +41,7 @@ export function DispatcherFilter({
users,
usersLoading = false,
currentUserId,
disabled = false,
}: DispatcherFilterProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
@ -86,6 +88,7 @@ export function DispatcherFilter({
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
disabled={disabled}
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
style={
!allSelected

View file

@ -16,6 +16,8 @@ type WeekNavProps = {
weekMonday: string;
onChange: (mondayIso: string) => void;
disabled?: boolean;
};
const VISIBLE_COUNT = 3;
@ -51,7 +53,7 @@ function WeekLiveBadge() {
);
}
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
const weeks = useMemo(
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
@ -91,7 +93,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
<NavArrowButton
disabled={!canGoBack}
disabled={disabled || !canGoBack}
onClick={() => setOffset((current) => current - 1)}
aria-label="Previous weeks"
>
@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
<NavPillButton
key={week.mondayIso}
active={isActive}
disabled={isDisabled}
disabled={disabled || isDisabled}
onClick={() => onChange(week.mondayIso)}
>
{week.label}
@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
})}
<NavArrowButton
disabled={!canGoForward}
disabled={disabled || !canGoForward}
onClick={() => setOffset((current) => current + 1)}
aria-label="Next weeks"
>

View file

@ -8,15 +8,22 @@ type WoTypeSegmentProps = {
value: WorkOrderTypeFilter;
onChange: (value: WorkOrderTypeFilter) => void;
disabled?: boolean;
};
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) {
return (
<SegmentGroup>
{OPTIONS.map((option) => (
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
<SegmentButton
key={option}
active={value === option}
disabled={disabled}
onClick={() => onChange(option)}
>
{option}
</SegmentButton>
))}

View file

@ -16,6 +16,7 @@ type WorkOrdersFilterBarProps = {
currentUserId?: string | number;
filteredCount: number;
scopeTotalCount: number;
disabled?: boolean;
};
export function WorkOrdersFilterBar({
@ -30,20 +31,25 @@ export function WorkOrdersFilterBar({
currentUserId,
filteredCount,
scopeTotalCount,
disabled = false,
}: WorkOrdersFilterBarProps) {
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
return (
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
<div
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4"
aria-disabled={disabled}
>
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
usersLoading={usersLoading}
currentUserId={currentUserId}
disabled={disabled}
/>
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
<div className="flex-1" />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
</div>

View file

@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
/>
</div>
<div className="max-h-52 overflow-y-auto">
<button
type="button"
onClick={() => {
onSave("", "");
setAnchorEl(null);
setSearch("");
}}
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
>
<span className="text-[13px] text-[var(--color-text-muted)]">Unassigned</span>
{!dispatcherId && <Check size={10} className="shrink-0 text-[var(--color-primary)]" />}
</button>
{filtered.map((d) => (
<button
key={String(d.id)}

View file

@ -1,108 +1,126 @@
import { useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
} from "@/domain/work-orders/utils/wo-closability";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
type StatusCellProps = {
wo: WorkOrderTableRow;
onChangeStatus: (status: WizardWOStatus) => void;
onOpenSlideOver: () => void;
onPastDueAttempt?: () => void;
};
function rowClosabilityGaps(wo: WorkOrderTableRow) {
return getClosabilityGaps({
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: wo.company,
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
});
}
function PastDueStatusCell({
onOpenSlideOver,
onPastDueAttempt,
}: {
onOpenSlideOver: () => void;
onPastDueAttempt?: () => void;
}) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its deadline. Update Schedule On to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
}}
>
<div
className="flex items-start gap-2 p-3"
style={{ background: "var(--status-pending-bg)" }}
>
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<div>
<Text
as="p"
style={{
fontSize: 11,
lineHeight: 1.375,
color: "var(--status-pending-text)",
}}
>
This work order is past due. Update Schedule On before the status can be updated.
</Text>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenSlideOver();
setAnchorEl(null);
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Update Schedule On →
</button>
</div>
</div>
</Popover>
</>
);
}
export function StatusCell({
wo,
onChangeStatus,
onOpenSlideOver,
onPastDueAttempt,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = rowClosabilityGaps(wo);
if (wo.isPastDue) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its deadline. Update Schedule On to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
}}
>
<div
className="flex items-start gap-2 p-3"
style={{ background: "var(--status-pending-bg)" }}
>
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<div>
<Text
as="p"
style={{
fontSize: 11,
lineHeight: 1.375,
color: "var(--status-pending-text)",
}}
>
This work order is past due. Update Schedule On before the status can be updated.
</Text>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenSlideOver();
setAnchorEl(null);
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Update Schedule On →
</button>
</div>
</div>
</Popover>
</>
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
);
}
@ -112,7 +130,6 @@ export function StatusCell({
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
@ -122,7 +139,6 @@ export function StatusCell({
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
@ -134,24 +150,38 @@ export function StatusCell({
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{ALL_WIZARD_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => {
onChangeStatus(status);
{ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
const missingTitle = isCompletedBlocked
? formatClosabilityMissingTitle(closabilityGaps)
: undefined;
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
>
<WoTableStatusLabel status={status} />
{wo.status === status && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
))}
return (
<button
key={status}
type="button"
disabled={isCompletedBlocked}
title={missingTitle}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onChangeStatus(status);
setAnchorEl(null);
}}
className={
isCompletedBlocked
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
}
>
<WoTableStatusLabel status={status} />
{Boolean(wo.status === status) && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
);
})}
</Popover>
</>
);

View file

@ -0,0 +1,48 @@
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
type UpliftCellProps = {
summary: UpliftSummary;
onOpen?: () => void;
};
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
if (!onOpen) {
return label ? (
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
);
}
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
title={label ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
>
{label && pillStyle ? (
<span
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={pillStyle}
>
{label}
</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}
</button>
);
}

View file

@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = {
onWeekChange: (mondayIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
@ -28,6 +29,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,
@ -62,6 +64,12 @@ export function useWoTableRowHandlers({
onOpenCompDoc(row);
}
: undefined,
onOpenUplifts: onOpenUplifts
? (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenUplifts(row);
}
: undefined,
onOpenMedia: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenMedia(row);
@ -136,6 +144,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,

View file

@ -1,7 +1,12 @@
import { Fragment, type ReactNode } from "react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import { Text } from "@/components/ui/text";
import {
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { WO_TABLE_COLS } from "./wo-table-cols";
import { WoTableSectionHeader } from "./wo-table-section-header";
type WoTableBodyProps = {
@ -15,11 +20,65 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
}
const unassignedRows = tableData.unassigned ?? [];
const showUnscheduled =
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
(tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
// SH-165: "still loading" and "zero unassigned" are distinct states. Without
// this split the pin announced "No unassigned work orders." while the queue
// was still paging board/search on first mount.
const showUnassignedLoading =
tableData.unassignedOpen &&
unassignedRows.length === 0 &&
!tableData.unassignedError &&
tableData.unassignedLoading;
const showUnassignedEmpty =
tableData.unassignedOpen &&
unassignedRows.length === 0 &&
!tableData.unassignedError &&
!tableData.unassignedLoading;
return (
<tbody>
<WoTableSectionHeader
variant="unassigned"
label="Unassigned"
count={unassignedRows.length}
collapsed={!tableData.unassignedOpen}
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
/>
{tableData.unassignedOpen && (
<>
{renderInlineRow(UNASSIGNED_DAY_KEY)}
{Boolean(tableData.unassignedError) && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="error" tone="error">
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
</Text>
</td>
</tr>
)}
{showUnassignedLoading && (
<tr aria-busy="true">
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
Loading unassigned work orders…
</Text>
</td>
</tr>
)}
{showUnassignedEmpty && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
No unassigned work orders.
</Text>
</td>
</tr>
)}
{unassignedRows.map((row) => renderRow(row))}
</>
)}
{showUnscheduled && (
<>
<WoTableSectionHeader

View file

@ -0,0 +1,119 @@
import type { LucideIcon } from "lucide-react";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { WO_TABLE_COLS } from "./wo-table-cols";
type CollapsibleSectionStyle = {
background: string;
borderTop: string;
borderBottom: string;
accentColor: string;
boxShadow: string;
labelFontFamily: string;
labelLetterSpacing: string;
};
const SECTION_STYLES: Record<"unassigned" | "unscheduled", CollapsibleSectionStyle> = {
unassigned: {
background: "rgba(99, 102, 241, 0.08)",
borderTop: "1px solid #C7D2FE",
borderBottom: "1px solid #C7D2FE",
accentColor: "var(--primary)",
boxShadow: "inset 3px 0 0 var(--primary)",
labelFontFamily: "var(--font-mono)",
labelLetterSpacing: "0.08em",
},
unscheduled: {
background: "rgba(217, 119, 6, 0.08)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
accentColor: "var(--warning)",
boxShadow: "inset 3px 0 0 var(--warning)",
labelFontFamily: "var(--font-mono)",
labelLetterSpacing: "0.08em",
},
};
type WoTableCollapsibleSectionHeaderProps = {
tone: "unassigned" | "unscheduled";
icon: LucideIcon;
label: string;
count: number;
collapsed?: boolean;
onToggleCollapse: () => void;
onAddWo?: () => void;
};
export function WoTableCollapsibleSectionHeader({
tone,
icon: Icon,
label,
count,
collapsed,
onToggleCollapse,
onAddWo,
}: WoTableCollapsibleSectionHeaderProps) {
const style = SECTION_STYLES[tone];
return (
<tr style={{ background: style.background }}>
<td
colSpan={WO_TABLE_COLS.length}
style={{
height: 40,
padding: "0 14px",
boxShadow: style.boxShadow,
borderTop: style.borderTop,
borderBottom: style.borderBottom,
}}
>
<div className="flex items-center gap-2.5">
<button
type="button"
className="flex flex-1 items-center gap-2.5"
aria-expanded={!collapsed}
aria-label={`${label}, ${count} work order${count !== 1 ? "s" : ""}${collapsed ? ", collapsed" : ", expanded"}`}
onClick={onToggleCollapse}
style={{
background: "transparent",
border: "none",
padding: 0,
cursor: "pointer",
color: "inherit",
}}
>
<Icon size={14} className="shrink-0" style={{ color: style.accentColor }} />
<span
className="uppercase"
style={{
fontFamily: style.labelFontFamily,
fontSize: 12,
fontWeight: 700,
letterSpacing: style.labelLetterSpacing,
color: style.accentColor,
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<span className="ml-auto flex items-center" style={{ color: style.accentColor }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{Boolean(onAddWo) && (
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={onAddWo}
>
<Plus size={13} />
Add WO
</button>
)}
</div>
</td>
</tr>
);
}

View file

@ -12,9 +12,10 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "uplift", label: "UPLIFT", w: 108 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_MIN_WIDTH = 1742;
export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -229,6 +229,7 @@ export function WoTableInlineRowCells({
<InlineMutedDashCell />
<InlineMutedDashCell />
<InlineMutedDashCell />
</>
);
}

View file

@ -10,7 +10,10 @@ import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import {
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
} from "@/domain/work-orders/types/work-order-table-row";
import {
draftHasScheduleDate,
getInlineCreateMissing,
@ -66,7 +69,7 @@ export function WoTableInlineRow({
onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false);

View file

@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
onOpen={
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
handlers.onOpenUplifts
? () => handlers.onOpenUplifts!(row)
: undefined
}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}

View file

@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
onView: (row: WorkOrderTableRow) => void;
onEdit: (row: WorkOrderTableRow) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
onScheduleDate: (id: string | number, date: string) => void;

View file

@ -1,14 +1,15 @@
import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react";
import { AlertTriangle, Plus, Users } from "lucide-react";
import { WO_TABLE_COLS } from "./wo-table-cols";
import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header";
type WoTableSectionHeaderProps = {
variant: "unscheduled" | "day";
variant: "unassigned" | "unscheduled" | "day";
label: string;
count: number;
isToday?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
onAddWo: () => void;
onAddWo?: () => void;
};
export function WoTableSectionHeader({
@ -20,58 +21,30 @@ export function WoTableSectionHeader({
onToggleCollapse,
onAddWo,
}: WoTableSectionHeaderProps) {
const isUnscheduled = variant === "unscheduled";
if (isUnscheduled) {
if (variant === "unassigned") {
return (
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
<td
colSpan={WO_TABLE_COLS.length}
onClick={onToggleCollapse}
style={{
height: 40,
padding: "0 14px",
cursor: "pointer",
boxShadow: "inset 3px 0 0 var(--warning)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
}}
>
<div className="flex items-center gap-2.5">
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--warning)",
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</div>
</td>
</tr>
<WoTableCollapsibleSectionHeader
tone="unassigned"
icon={Users}
label={label}
count={count}
collapsed={collapsed}
onToggleCollapse={() => onToggleCollapse?.()}
/>
);
}
if (variant === "unscheduled") {
return (
<WoTableCollapsibleSectionHeader
tone="unscheduled"
icon={AlertTriangle}
label={label}
count={count}
collapsed={collapsed}
onToggleCollapse={() => onToggleCollapse?.()}
onAddWo={onAddWo}
/>
);
}
@ -125,18 +98,17 @@ export function WoTableSectionHeader({
Today
</span>
)}
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
{Boolean(onAddWo) && (
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={onAddWo}
>
<Plus size={13} />
Add WO
</button>
)}
</div>
</td>
</tr>

View file

@ -33,6 +33,7 @@ type WoTableProps = {
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onClearSearch?: () => void;
onOpenAdvancedFilters?: () => void;
@ -49,6 +50,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onClearSearch,
onOpenAdvancedFilters,
@ -71,6 +73,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,

View file

@ -1,88 +1,22 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
wizardOpen: boolean;
onWizardOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
sites: LocationOption[];
pmTypes: string[];
vendorTechs: string[];
advOpen: boolean;
onAdvOpenChange: (open: boolean) => void;
advInitial: WorkOrderAdvancedFilters;
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
onClearAdvanced: () => void;
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
activeUpliftsRow: WorkOrderTableRow | null;
onCloseUplifts: () => void;
};
export function WorkOrdersListOverlays({
wizardOpen,
onWizardOpenChange,
users,
sites,
pmTypes,
vendorTechs,
advOpen,
onAdvOpenChange,
advInitial,
onApplyAdvanced,
onClearAdvanced,
activeDocRow,
onDocOpenChange,
completionDocs,
onSaveCompDoc,
onDateCompletedChange,
activeMediaRow,
onMediaOpenChange,
activeUpliftsRow,
onCloseUplifts,
}: WorkOrdersListOverlaysProps) {
return (
<>
<WorkOrdersAdvancedSearchSheet
open={advOpen}
onOpenChange={onAdvOpenChange}
initial={advInitial}
onApply={onApplyAdvanced}
onClearAll={onClearAdvanced}
sites={sites}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={onMediaOpenChange}
{activeUpliftsRow != null && (
<WorkOrderUpliftsDialog
row={activeUpliftsRow}
open={Boolean(activeUpliftsRow)}
onClose={onCloseUplifts}
/>
)}
</>

View file

@ -0,0 +1,95 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
type WorkOrdersListPagePanelsProps = {
page: ReturnType<typeof useWorkOrdersListPage>;
};
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
page;
return (
<>
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</>
);
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { Text } from "@/components/ui/text";
type RevokeUpliftDialogProps = {
open: boolean;
onClose: () => void;
onConfirm: (reason: string) => void;
pending?: boolean;
requireReason?: boolean;
title?: string;
description?: string;
};
export function RevokeUpliftDialog({
open,
onClose,
onConfirm,
pending = false,
requireReason = false,
title = "Revoke auto-approved uplift?",
description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
}: RevokeUpliftDialogProps) {
const [reason, setReason] = useState("");
// Clear only after the dialog closes (parent closes on mutation success).
useEffect(() => {
if (!open) {
setReason("");
}
}, [open]);
const handleClose = () => {
onClose();
};
const handleConfirm = () => {
onConfirm(reason.trim());
};
const reasonMissing = requireReason && reason.trim() === "";
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Text as="p" variant="description" className="mb-3">
{description}
</Text>
<TextField
label={requireReason ? "Reason (required)" : "Reason (optional)"}
fullWidth
multiline
minRows={2}
required={requireReason}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
color="error"
variant="contained"
disabled={pending || reasonMissing}
onClick={handleConfirm}
>
Revoke
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,85 @@
import { useState } from "react";
import { Button, TextField } from "@mui/material";
import { Text } from "@/components/ui/text";
import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftCreateFormProps = {
pending?: boolean;
remainingAllowance: number;
onSubmit: (input: { amount: number; notes: string }) => void | Promise<void>;
};
function amountHelperText(parsedAmount: number, remainingAllowance: number): string {
const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`;
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
return remaining;
}
if (parsedAmount <= remainingAllowance) {
return `${remaining} This request will auto-approve.`;
}
return `${remaining} This request will go to the admin approval queue.`;
}
export function WorkOrderUpliftCreateForm({
pending = false,
remainingAllowance,
onSubmit,
}: WorkOrderUpliftCreateFormProps) {
const [amount, setAmount] = useState("");
const [notes, setNotes] = useState("");
const parsedAmount = Number(amount);
const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
const handleSubmit = async () => {
try {
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
setAmount("");
setNotes("");
} catch {
// Mutation onError already toasts; keep form values after failure.
}
};
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<Text as="p" variant="label" className="mb-2">
Request uplift
</Text>
<div className="grid gap-2">
<TextField
label="Amount"
type="number"
size="small"
value={amount}
onChange={(event) => setAmount(event.target.value)}
helperText={amountHelperText(parsedAmount, remainingAllowance)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<TextField
label="Notes"
size="small"
multiline
minRows={2}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<TextField
label="Attachments"
size="small"
disabled
helperText="Attachment upload will be wired when BE accepts files."
/>
<Button
variant="contained"
disabled={!canSubmit || pending}
onClick={() => {
void handleSubmit();
}}
>
Create uplift
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { Button } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
import {
canRevokeWorkOrderUplift,
formatUpliftMoney,
formatUpliftStatusLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftListItemProps = {
uplift: WorkOrderUplift;
readOnly: boolean;
currentUserId: string | number | null | undefined;
pendingAction?: boolean;
onCancel?: () => void;
onRevoke?: () => void;
};
export function WorkOrderUpliftListItem({
uplift,
readOnly,
currentUserId,
pendingAction = false,
onCancel,
onRevoke,
}: WorkOrderUpliftListItemProps) {
const pillStyle = getUpliftStatusPillStyle(uplift.status);
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={pillStyle}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
<Text as="span" variant="label">
{formatUpliftMoney(uplift.amount)}
</Text>
</div>
{Boolean(uplift.notes) && (
<Text as="p" variant="description" className="mb-2">
{uplift.notes}
</Text>
)}
{Boolean(uplift.requestedByName) && (
<Text as="p" variant="caption" tone="muted">
Requested by {uplift.requestedByName}
</Text>
)}
{showCancel && (
<Button
size="small"
color="inherit"
disabled={pendingAction}
onClick={onCancel}
className="mt-2"
>
Cancel pending
</Button>
)}
{showRevoke && (
<Button
size="small"
color="error"
disabled={pendingAction}
onClick={onRevoke}
className="mt-2"
>
Revoke
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
import { CircularProgress } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
currentUserId: string | number | null | undefined;
readOnlyStatusLabel?: string;
isLoading: boolean;
error: Error | null;
uplifts: WorkOrderUplift[];
remainingAllowance: number;
actionPending: boolean;
createPending: boolean;
onRetry: () => void;
onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise<void>;
onCancelUplift: (upliftId: string | number) => void;
onRevokeUplift: (upliftId: string | number) => void;
};
export function WorkOrderUpliftsDialogContent({
readOnly,
currentUserId,
readOnlyStatusLabel,
isLoading,
error,
uplifts,
remainingAllowance,
actionPending,
createPending,
onRetry,
onCreate,
onCancelUplift,
onRevokeUplift,
}: WorkOrderUpliftsDialogContentProps) {
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
return (
<>
{readOnly && (
<Text as="p" variant="description" tone="muted">
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
</Text>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={onRetry}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{!isLoading && !error && uplifts.length === 0 && (
<Text as="p" variant="description" tone="muted">
No uplift requests yet.
</Text>
)}
{!isLoading &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
/>
))}
{!readOnly && !isLoading && hasOpenRequest && (
<Text as="p" variant="description" tone="muted">
Cancel the pending request to file a new one.
</Text>
)}
{!readOnly && !isLoading && !hasOpenRequest && (
<WorkOrderUpliftCreateForm
pending={createPending}
remainingAllowance={remainingAllowance}
onSubmit={onCreate}
/>
)}
</>
);
}

View file

@ -0,0 +1,122 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
getRemainingUpliftAllowance,
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
id: string | number;
status: WorkOrderUpliftStatus;
};
type WorkOrderUpliftsDialogProps = {
row: WorkOrderTableRow | null;
open: boolean;
onClose: () => void;
};
export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
const { user } = useAuthContext();
const workOrderId = row?.id ?? null;
const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
const {
data: uplifts = [],
isLoading,
error,
refetch,
} = useWorkOrderUplifts(open ? workOrderId : null);
const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
const revokeDialogCopy = useMemo(() => {
if (revokeTarget?.status === "approved") {
return {
title: "Revoke approved uplift?",
description:
"Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.",
};
}
return {
title: "Revoke auto-approved uplift?",
description:
"This will mark the uplift as revoked. Optional reason is stored with the decision.",
};
}, [revokeTarget?.status]);
const handleRevokeUplift = (upliftId: string | number) => {
const uplift = uplifts.find((item) => String(item.id) === String(upliftId));
if (!uplift) {
return;
}
setRevokeTarget({ id: upliftId, status: uplift.status });
};
const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts);
return (
<>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
<DialogContent className="flex flex-col gap-3">
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}
uplifts={uplifts}
remainingAllowance={remainingAllowance}
actionPending={actionPending}
createPending={createUplift.isPending}
onRetry={() => void refetch()}
onCreate={async (input) => {
await createUplift.mutateAsync(input);
}}
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
onRevokeUplift={handleRevokeUplift}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title={revokeDialogCopy.title}
description={revokeDialogCopy.description}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) {
return;
}
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</>
);
}

View file

@ -1,22 +1,29 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import {
useWorkOrdersBoard,
useWorkOrdersBoardSearch,
useWorkOrdersUnassignedQueue,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UseQueryResult } from "@tanstack/react-query";
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
export type WorkOrdersBoardSource = {
advActive: boolean;
data: WorkOrderBoardListResult | undefined;
unassignedQueue: WorkOrderTableRow[];
unassignedError: Error | null;
unassignedLoading: boolean;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
@ -67,13 +74,35 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
error: searchError,
refetch: refetchSearch,
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
const {
data: queueData,
isLoading: queueLoading,
isFetching: queueFetching,
error: queueError,
refetch: refetchQueue,
} = useWorkOrdersUnassignedQueue(!advActive);
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
useCallback(
async (options) => {
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
return boardResult;
},
[refetchBoard, refetchQueue],
);
return {
advActive,
data: advActive ? searchData : boardData,
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
unassignedError: advActive ? null : (queueError ?? null),
// SH-165: the pinned queue has its own first-load state. Folding it into
// isFetching only made "still paging board/search" indistinguishable from
// "zero unassigned", so the pin announced an empty queue while loading.
unassignedLoading: advActive ? false : queueLoading,
isLoading: advActive ? searchLoading : boardLoading,
isFetching: advActive ? searchFetching : boardFetching,
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
error: advActive ? searchError : boardError,
refetch: advActive ? refetchSearch : refetchBoard,
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
};
}

View file

@ -19,6 +19,7 @@ export function useWorkOrdersListPage() {
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
@ -62,6 +63,11 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const activeUpliftsRow = useMemo(() => {
if (!upliftsRow) return null;
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
setDocRow,
mediaRow,
setMediaRow,
setUpliftsRow,
completionDocs,
filters,
tableData,
@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
activeSlideOverRow,
activeDocRow,
activeMediaRow,
activeUpliftsRow,
handleOpenSlideOver,
handleCloseSlideOver,
handleConfirmCancel,

View file

@ -3,10 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
buildDayGroups,
composeBoardSections,
filterByBarAndSearch,
mergeWeeklyAndUnassignedQueue,
rowIdSet,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
@ -14,12 +18,31 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
export function useWorkOrdersTableData(filters: FiltersReturn) {
const { advActive, data, isLoading, isFetching, error, refetch } =
useWorkOrdersBoardSource(filters);
const {
advActive,
data,
unassignedQueue,
unassignedError,
unassignedLoading,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrdersBoardSource(filters);
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
const items = useMemo(
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
[weeklyItems, unassignedQueue],
);
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
const local = useWorkOrdersTableLocalState({
items: data?.items ?? [],
items,
weekMonday: filters.weekMonday,
advActive,
isFetching,
@ -38,10 +61,18 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
return displayRows;
}, [displayRows, filters.barFilters, filters.search, advActive]);
const { scheduled, unscheduled } = useMemo(
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
[advActive, advResults, filtered],
);
const { unassigned, scheduled, unscheduled } = useMemo(() => {
if (advActive) {
const split = splitScheduledUnscheduled(advResults);
return { unassigned: [], ...split };
}
return composeBoardSections({
displayRows,
weekRows: filtered,
weeklyIds,
queueIds,
});
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
@ -49,7 +80,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
const searchActive = filters.search.trim() !== "";
const visibleRows = advActive ? advResults : filtered;
const noSearchResults = visibleRows.length === 0 && searchActive;
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const isEmptyWeek =
!advActive &&
scheduled.length === 0 &&
unassigned.length === 0 &&
unscheduled.length === 0 &&
!searchActive;
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
const todayKey =
@ -70,6 +106,9 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
refetch,
advActive,
advResults,
unassigned,
unassignedError,
unassignedLoading,
unscheduled,
displayGroups,
searchActive,
@ -79,6 +118,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
draftDayKey: local.draftDayKey,
draft: local.draft,
setDraft: local.setDraft,
unassignedOpen: local.unassignedOpen,
setUnassignedOpen: local.setUnassignedOpen,
unschedOpen: local.unschedOpen,
setUnschedOpen: local.setUnschedOpen,
selectedId: local.selectedId,
@ -99,7 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive
? (data?.totalCount ?? advResults.length)
: (data?.returnedCount ?? visibleRows.length),
: (data?.returnedCount ?? weeklyItems.length),
search: filters.search,
};
}

View file

@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({
setDraft: ui.setDraft,
startDayDraft: ui.startDayDraft,
cancelDraft: ui.cancelDraft,
unassignedOpen: ui.unassignedOpen,
setUnassignedOpen: ui.setUnassignedOpen,
unschedOpen: ui.unschedOpen,
setUnschedOpen: ui.setUnschedOpen,
selectedId: ui.selectedId,

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from "react";
import {
EMPTY_TABLE_DRAFT,
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
type WorkOrderTableDraft,
} from "@/domain/work-orders/types/work-order-table-row";
@ -11,7 +12,7 @@ export type DragVizState = {
};
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
if (key === UNSCHEDULED_DAY_KEY) {
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
return "";
}
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?:
return "";
}
/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */
/** Draft, selection, pinned-section accordions, drag viz and flash scroll helpers. */
export function useWorkOrdersTableUiState(weekMonday: string) {
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
const [unassignedOpen, setUnassignedOpen] = useState(true);
const [unschedOpen, setUnschedOpen] = useState(true);
const [selectedId, setSelectedId] = useState<string | number | null>(null);
const [flashId, setFlashId] = useState<string | number | null>(null);
@ -50,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
(key: string, scheduledIso?: string) => {
setDraftDayKey(key);
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
if (key === UNSCHEDULED_DAY_KEY) {
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
setDraft({ ...EMPTY_TABLE_DRAFT });
return;
}
@ -85,6 +87,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
setDraft,
startDayDraft,
cancelDraft,
unassignedOpen,
setUnassignedOpen,
unschedOpen,
setUnschedOpen,
selectedId,

View file

@ -1,15 +1,10 @@
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
const { filters, tableData } = page;
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -49,6 +44,7 @@ export default function WorkOrdersListPage() {
currentUserId={user?.id}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
disabled={filters.advApplied != null}
/>
{filters.advApplied != null && (
@ -86,79 +82,13 @@ export default function WorkOrdersListPage() {
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}
/>
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={page.users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListPagePanels page={page} />
</Box>
);
}

View file

@ -0,0 +1,29 @@
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
const rows: WorkOrderTableRow[] = [];
let page = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (rows.length < totalCount) {
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
throw new Error(
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
);
}
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
rows.push(...result.items);
totalCount = result.totalCount;
if (result.items.length === 0) {
break;
}
page += 1;
}
return rows;
}

View file

@ -0,0 +1,61 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapWorkOrderUplift,
mapWorkOrderUplifts,
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
function extractUpliftItems(data: unknown): unknown[] {
if (Array.isArray(data)) {
return data;
}
const record = typeof data === "object" && data != null ? (data as Record<string, unknown>) : {};
const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts;
return Array.isArray(items) ? items : [];
}
export const workOrderUpliftsApi = {
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
const unwrapped = handleApiResponse<unknown>(data);
return mapWorkOrderUplifts(extractUpliftItems(unwrapped));
},
create: async (
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), {
amount: input.amount,
notes: input.notes,
});
return mapWorkOrderUplift(handleApiResponse(data));
},
cancel: async (
workOrderId: string | number,
upliftId: string | number,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(
API_PATHS.workOrder.upliftCancel(workOrderId, upliftId),
{},
);
return mapWorkOrderUplift(handleApiResponse(data));
},
revoke: async (
workOrderId: string | number,
upliftId: string | number,
reason?: string,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), {
reason,
});
return mapWorkOrderUplift(handleApiResponse(data));
},
};

View file

@ -1,4 +1,5 @@
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
DOC_STATUS_API,
LIFECYCLE_STATUS_API_TO_FE,
@ -8,6 +9,7 @@ import {
import type {
CompletionDocStatus,
WorkOrderTableRow,
WorkOrderUpliftSummary,
} from "@/domain/work-orders/types/work-order-table-row";
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
@ -215,6 +217,55 @@ function readFlagColor(record: Record<string, unknown>): string | null {
return null;
}
function readUpliftSummary(item: Record<string, unknown>): WorkOrderUpliftSummary | undefined {
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
const hasNested = Object.keys(nested).length > 0;
const source = hasNested ? nested : item;
// Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present.
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
const nestedCount = readNumber(source, "pendingCount", "PendingCount");
const pendingCount = nestedCount ?? boardCount ?? 0;
const hasUplift =
readBool(source, "hasUplift", "HasUplift") ||
readBool(item, "hasUplift", "HasUplift") ||
pendingCount > 0;
// No nested summary and no board count → omit (legacy payloads without the contract field).
if (!hasNested && boardCount == null && !hasUplift) {
return undefined;
}
const summary: WorkOrderUpliftSummary = {
hasUplift: hasUplift || pendingCount > 0,
pendingCount,
};
const primaryStatus =
readString(nested, "primaryStatus", "PrimaryStatus") ||
readString(item, "primaryStatus", "PrimaryStatus");
if (primaryStatus) {
summary.primaryStatus = primaryStatus;
}
return summary;
}
function readHasPendingUplift(
item: Record<string, unknown>,
upliftSummary: WorkOrderUpliftSummary | undefined,
): boolean | undefined {
if ("hasPendingUplift" in item || "HasPendingUplift" in item) {
return Boolean(item.hasPendingUplift ?? item.HasPendingUplift);
}
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
if (boardCount != null) {
return boardCount > 0;
}
if (upliftSummary != null) {
return upliftSummary.pendingCount > 0;
}
return undefined;
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
@ -269,6 +320,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const locationIdRaw = item.locationId ?? item.LocationId;
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
const boardUpliftSummary = readUpliftSummary(item);
const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
const row: WorkOrderTableRow = {
id: readId(item),
@ -279,7 +333,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
completedDate: toDateInputValue(
readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,
@ -316,6 +372,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
flagColor: readFlagColor(item),
upliftSummary,
hasPendingUplift,
};
return row;

View file

@ -0,0 +1,158 @@
import type {
UpliftSummary,
WorkOrderUplift,
WorkOrderUpliftStatus,
} from "@/domain/work-orders/types/work-order-uplift";
import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
}
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number") {
return value;
}
if (typeof value === "string" && value !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
return null;
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return false;
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") {
return value;
}
return "";
}
function readOptionalId(
record: Record<string, unknown>,
...keys: string[]
): string | number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" || typeof value === "number") {
return value;
}
}
return null;
}
const STATUS_SET = new Set<string>(WORK_ORDER_UPLIFT_STATUSES);
function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus {
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
if (STATUS_SET.has(normalized)) {
return normalized as WorkOrderUpliftStatus;
}
if (normalized === "canceled") {
return "cancelled";
}
if (normalized === "autoapproved") {
return "auto_approved";
}
return "pending";
}
export function mapUpliftSummary(raw: unknown): UpliftSummary {
const item = asRecord(raw);
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
const hasNested = Object.keys(nested).length > 0;
const source = hasNested ? nested : item;
const pendingCount =
readNumber(source, "pendingCount", "PendingCount") ??
readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ??
0;
const hasUplift =
readBool(source, "hasUplift", "HasUplift") ||
readBool(item, "hasUplift", "HasUplift") ||
pendingCount > 0;
// Never treat the work-order lifecycle status/Status as uplift primaryStatus.
const primaryStatusRaw = hasNested
? readString(source, "primaryStatus", "PrimaryStatus", "status", "Status")
: readString(item, "primaryStatus", "PrimaryStatus");
const amount = hasNested
? readNumber(source, "amount", "Amount")
: readNumber(item, "amount", "Amount");
const summary: UpliftSummary = {
hasUplift,
pendingCount,
};
if (primaryStatusRaw) {
summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw);
}
if (amount != null) {
summary.amount = amount;
}
return summary;
}
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
const item = asRecord(raw);
const statusRaw = readString(item, "status", "Status");
return {
id: readId(item),
status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending",
amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0,
notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedById: readOptionalId(
item,
"requestedById",
"RequestedById",
"requesterId",
"RequesterId",
),
requestedByName: readString(
item,
"requestedByName",
"RequestedByName",
"requestedByVendorName",
"RequestedByVendorName",
),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
};
}
export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] {
return rawItems.map(mapWorkOrderUplift);
}

View file

@ -1,4 +1,6 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftFilterStatus } from "./work-order-uplift-filter";
import {
ALL_WIZARD_STATUSES,
WO_TYPE_META,
@ -26,11 +28,19 @@ export interface WorkOrderAdvancedFilters {
customTo: string;
sites: Set<string>;
types: Set<WOType>;
severities: Set<WorkOrderSeverity>;
dispatchers: Set<string>;
statuses: Set<WizardWOStatus>;
rescheduled: boolean;
carriedOver: boolean;
addOn: boolean;
pmTypes: Set<string>;
vendorTechs: Set<string>;
docs: Set<CompletionDocFilter>;
flagColors: Set<string>;
internalOnly: boolean;
hasUplift: boolean;
upliftStatuses: Set<UpliftFilterStatus>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
@ -39,11 +49,19 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
customTo: "",
sites: new Set(),
types: new Set(),
severities: new Set(),
dispatchers: new Set(),
statuses: new Set(),
rescheduled: false,
carriedOver: false,
addOn: false,
pmTypes: new Set(),
vendorTechs: new Set(),
docs: new Set(),
flagColors: new Set(),
internalOnly: false,
hasUplift: false,
upliftStatuses: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
@ -71,4 +89,11 @@ export const WO_TYPE_DESCRIPTIONS: Record<WOType, string> = Object.fromEntries(
export { ALL_WIZARD_STATUSES, WO_TYPES };
export type AdvancedFilterSetKey =
"sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";
| "sites"
| "types"
| "dispatchers"
| "statuses"
| "pmTypes"
| "vendorTechs"
| "docs"
| "flagColors";

View file

@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = {
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
/** Non-terminal lifecycle statuses for the global Unassigned queue (SH-165). */
export const NON_TERMINAL_LIFECYCLE_STATUSES: LifecycleStatusApi[] = [
LIFECYCLE_STATUS_API.Incomplete,
LIFECYCLE_STATUS_API.Pending,
LIFECYCLE_STATUS_API.Scheduled,
LIFECYCLE_STATUS_API["En Route"],
LIFECYCLE_STATUS_API["On Site"],
LIFECYCLE_STATUS_API["In Progress"],
LIFECYCLE_STATUS_API.Rescheduled,
LIFECYCLE_STATUS_API["Pending Quote"],
];
/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM",
@ -179,6 +191,11 @@ export interface WorkOrderBoardRowApi {
primaryDispatchId?: number | null;
rowVersion?: string | null;
dispatchRowVersion?: string | null;
/**
* Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract).
* Drives the Completed closability gate.
*/
pendingUpliftCount?: number;
}
export interface WorkOrderBoardCountsApi {
@ -251,6 +268,14 @@ export interface WorkOrderBoardSearchQueryParams {
pmTypes?: string[];
vendorIds?: number[];
docStatuses?: number[];
severities?: number[];
rescheduled?: boolean;
carriedOver?: boolean;
addOn?: boolean;
flagColors?: string[];
internalOnly?: boolean;
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string;

View file

@ -1,10 +1,17 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
export type WorkOrderUpliftSummary = {
hasUplift: boolean;
pendingCount: number;
primaryStatus?: string;
};
export interface WorkOrderTableRow {
id: string | number;
woNumber: string;
@ -15,7 +22,7 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
/** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
completedDate: string;
targetWeek: string;
dayGroup: string;
@ -59,6 +66,8 @@ export interface WorkOrderTableRow {
dispatchRowVersion: string;
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
flagColor: string | null;
upliftSummary?: UpliftSummary | null;
hasPendingUplift?: boolean;
}
export interface WorkOrderTableDraft {
@ -130,3 +139,4 @@ export type WorkOrderDayGroup = {
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
export const UNASSIGNED_DAY_KEY = "__unassigned__" as const;

View file

@ -0,0 +1,16 @@
/** Uplift statuses exposed in advanced search (excludes cancelled/revoked). */
export type UpliftFilterStatus = "pending" | "approved" | "auto_approved" | "rejected";
export const UPLIFT_FILTER_STATUSES: UpliftFilterStatus[] = [
"pending",
"approved",
"auto_approved",
"rejected",
];
export const UPLIFT_FILTER_LABELS: Record<UpliftFilterStatus, string> = {
pending: "Pending",
approved: "Approved",
auto_approved: "Auto-approved",
rejected: "Rejected",
};

View file

@ -0,0 +1,41 @@
export const WORK_ORDER_UPLIFT_STATUSES = [
"pending",
"approved",
"auto_approved",
"rejected",
"cancelled",
"revoked",
] as const;
export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number];
export interface UpliftSummary {
hasUplift: boolean;
pendingCount: number;
primaryStatus?: WorkOrderUpliftStatus;
amount?: number;
}
export interface WorkOrderUplift {
id: string | number;
status: WorkOrderUpliftStatus;
amount: number;
notes: string;
requestedAt: string;
requestedById: string | number | null;
requestedByName: string;
decidedAt: string;
decidedByName: string;
decisionNote: string;
}
export interface CreateWorkOrderUpliftInput {
amount: number;
notes: string;
attachments?: File[];
}
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
hasUplift: false,
pendingCount: 0,
};

View file

@ -57,6 +57,7 @@ export type {
export {
EMPTY_TABLE_DRAFT,
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
WORK_ORDERS_TABLE_PAGE_SIZE,
} from "./work-order-table-row";

View file

@ -0,0 +1,87 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
function invalidateUpliftQueries(
queryClient: ReturnType<typeof useQueryClient>,
workOrderId: string | number,
): void {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
export function useWorkOrderUplifts(
workOrderId: string | number | null | undefined,
): UseQueryResult<WorkOrderUplift[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
queryFn: () =>
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
enabled: workOrderId != null && workOrderId !== "",
});
}
export function useCreateWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, CreateWorkOrderUpliftInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateWorkOrderUpliftInput) =>
workOrderUpliftsApi.create(workOrderId, input),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift request created");
},
onError: (error) => {
toast.error(error.message || "Failed to create uplift");
},
});
}
export function useCancelWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift cancelled");
},
onError: (error) => {
toast.error(error.message || "Failed to cancel uplift");
},
});
}
export function useRevokeWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, { upliftId: string | number; reason?: string }> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) =>
workOrderUpliftsApi.revoke(workOrderId, upliftId, reason),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift revoked");
},
onError: (error) => {
toast.error(error.message || "Failed to revoke uplift");
},
});
}

View file

@ -1,4 +1,5 @@
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type {
WorkOrdersListParams,
@ -10,6 +11,7 @@ import type {
WorkOrderBoardQueryParams,
WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { queryKeys } from "@/infra/query-key/query-key";
export function useWorkOrdersList(
@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch(
});
}
export function useWorkOrdersUnassignedQueue(
enabled = true,
): UseQueryResult<WorkOrderTableRow[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.unassignedQueue(),
queryFn: fetchUnassignedBoardRows,
enabled,
placeholderData: keepPreviousData,
});
}
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.dispatchers(),

View file

@ -0,0 +1,283 @@
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
import {
ASSIGNEE_FILTER_UNASSIGNED,
DATE_RANGE_OPTIONS,
DOC_FILTER_LABELS,
type CompletionDocFilter,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import {
SEVERITY_LEVELS,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import {
UPLIFT_FILTER_LABELS,
type UpliftFilterStatus,
} from "@/domain/work-orders/types/work-order-uplift-filter";
import { MONTH_NAMES } from "@/domain/work-orders/utils/wizard-date-utils";
export interface AdvancedFilterChip {
key: string;
group: string;
value?: string;
count?: number;
accent?: boolean;
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
}
type ChipBuilderContext = {
siteNameOf: (id: string) => string;
dispatcherNameOf: (id: string) => string;
};
type SetChipInput = {
chips: AdvancedFilterChip[];
key: string;
group: string;
set: Set<string>;
nameOf: (value: string) => string;
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
};
function pushSetChip(input: SetChipInput): void {
const { chips, key, group, set, nameOf, clear } = input;
if (!set.size) return;
if (set.size <= 2) {
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
return;
}
chips.push({ key, group, count: set.size, clear });
}
function fmtChipDate(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
const [, m, d] = iso.split("-").map(Number);
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
}
function severityLabel(level: WorkOrderSeverity): string {
return SEVERITY_LEVELS[level].label;
}
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
if (filters.dateRange === "this-week") return;
const value =
filters.dateRange === "custom"
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
chips.push({
key: "date",
group: "Date",
value,
clear: (current) => ({
...current,
dateRange: "this-week",
customFrom: "",
customTo: "",
}),
});
}
export function appendSetChips(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
ctx: ChipBuilderContext,
) {
pushSetChip({
chips,
key: "sites",
group: "Site",
set: filters.sites,
nameOf: ctx.siteNameOf,
clear: (current) => ({ ...current, sites: new Set() }),
});
pushSetChip({
chips,
key: "types",
group: "Type of WO",
set: filters.types as Set<string>,
nameOf: (v) => v,
clear: (current) => ({ ...current, types: new Set() }),
});
}
export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
if (!filters.severities?.size) return;
const clearSeverity = (current: WorkOrderAdvancedFilters) => ({
...current,
severities: new Set<WorkOrderSeverity>(),
});
if (filters.severities.size <= 2) {
chips.push({
key: "severity",
group: "Severity",
value: [...filters.severities].map(severityLabel).join(", "),
clear: clearSeverity,
});
return;
}
chips.push({
key: "severity",
group: "Severity",
count: filters.severities.size,
clear: clearSeverity,
});
}
export function appendDispatcherChips(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
ctx: ChipBuilderContext,
) {
const realDispatchers = new Set(
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
);
pushSetChip({
chips,
key: "disp",
group: "Assigned to",
set: realDispatchers,
nameOf: ctx.dispatcherNameOf,
clear: (current) => ({
...current,
dispatchers: new Set(
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
),
}),
});
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
chips.push({
key: "disp-unassigned",
group: "Assigned to",
value: "Unassigned",
accent: true,
clear: (current) => {
const next = new Set(current.dispatchers);
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
return { ...current, dispatchers: next };
},
});
}
}
export function appendStatusPmVendorDocChips(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
pushSetChip({
chips,
key: "status",
group: "Status",
set: filters.statuses as Set<string>,
nameOf: (v) => v,
clear: (current) => ({ ...current, statuses: new Set() }),
});
pushSetChip({
chips,
key: "pm",
group: "PM type",
set: filters.pmTypes,
nameOf: (v) => v,
clear: (current) => ({ ...current, pmTypes: new Set() }),
});
pushSetChip({
chips,
key: "vendor",
group: "Vendor / tech",
set: filters.vendorTechs,
nameOf: (v) => v,
clear: (current) => ({ ...current, vendorTechs: new Set() }),
});
pushSetChip({
chips,
key: "doc",
group: "Comp doc",
set: filters.docs as Set<string>,
nameOf: (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
clear: (current) => ({ ...current, docs: new Set() }),
});
}
export function appendIndicatorChips(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
if (filters.rescheduled) {
chips.push({
key: "rescheduled",
group: "Indicator",
value: "Rescheduled",
clear: (current) => ({ ...current, rescheduled: false }),
});
}
if (filters.carriedOver) {
chips.push({
key: "carriedover",
group: "Indicator",
value: "Carried Over",
clear: (current) => ({ ...current, carriedOver: false }),
});
}
if (filters.addOn) {
chips.push({
key: "addon",
group: "Indicator",
value: "Add-On",
clear: (current) => ({ ...current, addOn: false }),
});
}
}
export function appendFlagAndInternalChips(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
pushSetChip({
chips,
key: "flags",
group: "Flag",
set: filters.flagColors,
nameOf: (v) => FLAG_COLORS.find((c) => c.value === v)?.name ?? v,
clear: (current) => ({ ...current, flagColors: new Set() }),
});
if (filters.internalOnly) {
chips.push({
key: "internal",
group: "WO#",
value: "Internal (SH)",
clear: (current) => ({ ...current, internalOnly: false }),
});
}
}
export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
if (!filters.hasUplift) return;
const clearUplift = (current: WorkOrderAdvancedFilters) => ({
...current,
hasUplift: false,
upliftStatuses: new Set<UpliftFilterStatus>(),
});
if (filters.upliftStatuses.size) {
pushSetChip({
chips,
key: "uplift",
group: "Uplift",
set: filters.upliftStatuses as Set<string>,
nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v,
clear: clearUplift,
});
return;
}
chips.push({
key: "uplift",
group: "Uplift",
value: "Has uplift",
clear: clearUplift,
});
}

View file

@ -44,6 +44,18 @@ const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
const matchesDoc: AdvancedFilterPredicate = (row, filters) =>
!filters.docs.size || filters.docs.has(row.docStatus as never);
const matchesRescheduled: AdvancedFilterPredicate = (row, filters) =>
!filters.rescheduled || row.rescheduleCount >= 2;
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
!filters.carriedOver || row.carriedOver >= 2;
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
const matchesFlagColors: AdvancedFilterPredicate = (row, filters) =>
!filters.flagColors.size || (!!row.flagColor && filters.flagColors.has(row.flagColor));
/** One predicate per filter dimension; a row matches when every predicate is true. */
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesDateRange,
@ -54,4 +66,8 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesPmType,
matchesVendorTech,
matchesDoc,
matchesRescheduled,
matchesCarriedOver,
matchesInternalOnly,
matchesFlagColors,
];

View file

@ -1,59 +1,60 @@
import {
ASSIGNEE_FILTER_UNASSIGNED,
DATE_RANGE_OPTIONS,
DOC_FILTER_LABELS,
EMPTY_ADVANCED_FILTERS,
type CompletionDocFilter,
type DateRangeKey,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import {
MONTH_NAMES,
addDaysIso,
mondayOf,
pad2,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
appendDateChip,
appendDispatcherChips,
appendFlagAndInternalChips,
appendIndicatorChips,
appendSetChips,
appendSeverityChip,
appendStatusPmVendorDocChips,
appendUpliftChip,
type AdvancedFilterChip,
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
export interface AdvancedFilterChip {
key: string;
group: string;
value?: string;
count?: number;
accent?: boolean;
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
}
export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders";
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
return {
...EMPTY_ADVANCED_FILTERS,
...filters,
sites: new Set(filters.sites),
types: new Set(filters.types),
severities: new Set(filters.severities ?? []),
dispatchers: new Set(filters.dispatchers),
statuses: new Set(filters.statuses),
pmTypes: new Set(filters.pmTypes),
vendorTechs: new Set(filters.vendorTechs),
docs: new Set(filters.docs),
flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []),
};
}
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
let count = 0;
if (filters.dateRange !== "this-week") count += 1;
if (filters.sites.size) count += 1;
if (filters.types.size) count += 1;
if (filters.dispatchers.size) count += 1;
if (filters.statuses.size) count += 1;
if (filters.pmTypes.size) count += 1;
if (filters.vendorTechs.size) count += 1;
if (filters.docs.size) count += 1;
return count;
}
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
filters.dateRange !== "this-week",
Boolean(filters.sites?.size),
Boolean(filters.types?.size),
Boolean(filters.severities?.size),
Boolean(filters.dispatchers?.size),
Boolean(filters.statuses?.size),
filters.rescheduled,
filters.carriedOver,
filters.addOn,
Boolean(filters.pmTypes?.size),
Boolean(filters.vendorTechs?.size),
Boolean(filters.docs?.size),
Boolean(filters.flagColors?.size),
filters.internalOnly,
filters.hasUplift,
];
function fmtChipDate(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
const [, m, d] = iso.split("-").map(Number);
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
}
function monthStart(y: number, m: number): string {
@ -91,22 +92,6 @@ export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string
}
}
function setChip(
chips: AdvancedFilterChip[],
key: string,
group: string,
set: Set<string>,
nameOf: (value: string) => string,
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
) {
if (!set.size) return;
if (set.size <= 2) {
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
return;
}
chips.push({ key, group, count: set.size, clear });
}
export function buildAdvancedFilterChips(
filters: WorkOrderAdvancedFilters,
options?: {
@ -115,100 +100,19 @@ export function buildAdvancedFilterChips(
},
): AdvancedFilterChip[] {
const chips: AdvancedFilterChip[] = [];
const siteNameOf = options?.siteNameOf ?? ((v) => v);
const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
const ctx = {
siteNameOf: options?.siteNameOf ?? ((v: string) => v),
dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v),
};
if (filters.dateRange !== "this-week") {
const value =
filters.dateRange === "custom"
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
chips.push({
key: "date",
group: "Date",
value,
clear: (current) => ({
...current,
dateRange: "this-week",
customFrom: "",
customTo: "",
}),
});
}
setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
...current,
sites: new Set(),
}));
setChip(
chips,
"types",
"Type of WO",
filters.types as Set<string>,
(v) => v,
(current) => ({ ...current, types: new Set() }),
);
const realDispatchers = new Set(
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
);
setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
...current,
dispatchers: new Set(
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
),
}));
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
chips.push({
key: "disp-unassigned",
group: "Assigned to",
value: "Unassigned",
accent: true,
clear: (current) => {
const next = new Set(current.dispatchers);
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
return { ...current, dispatchers: next };
},
});
}
setChip(
chips,
"status",
"Status",
filters.statuses as Set<string>,
(v) => v,
(current) => ({ ...current, statuses: new Set() }),
);
setChip(
chips,
"pm",
"PM type",
filters.pmTypes,
(v) => v,
(current) => ({ ...current, pmTypes: new Set() }),
);
setChip(
chips,
"vendor",
"Vendor / tech",
filters.vendorTechs,
(v) => v,
(current) => ({ ...current, vendorTechs: new Set() }),
);
setChip(
chips,
"doc",
"Comp doc",
filters.docs as Set<string>,
(v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
(current) => ({ ...current, docs: new Set() }),
);
appendDateChip(chips, filters);
appendSetChips(chips, filters, ctx);
appendSeverityChip(chips, filters);
appendDispatcherChips(chips, filters, ctx);
appendStatusPmVendorDocChips(chips, filters);
appendIndicatorChips(chips, filters);
appendFlagAndInternalChips(chips, filters);
appendUpliftChip(chips, filters);
return chips;
}

View file

@ -1,11 +1,15 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import {
BOARD_DISPATCHER_UNASSIGNED,
DATE_RANGE_TO_PRESET,
DOC_STATUS_API,
LIFECYCLE_STATUS_API,
NON_TERMINAL_LIFECYCLE_STATUSES,
type WorkOrderBoardQueryParams,
type WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board";
@ -16,6 +20,18 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
/**
* Wide Custom window for SH-165. GET /board/search defaults omitted
* `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would
* hide scheduled-unassigned WOs outside the current week.
*/
export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01";
export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31";
function toBoardDispatcherId(id: string): string {
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
return BOARD_DISPATCHER_UNASSIGNED;
@ -77,6 +93,47 @@ function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefin
return list.length > 0 ? list : undefined;
}
function resolveVendorIds(
vendorTechs: Iterable<string>,
vendorIdsByCompanyName?: Map<string, number>,
): number[] | undefined {
if (!vendorIdsByCompanyName) return undefined;
const vendorIds: number[] = [];
for (const name of vendorTechs) {
const id = vendorIdsByCompanyName.get(name);
if (typeof id === "number") {
vendorIds.push(id);
}
}
return vendorIds.length > 0 ? vendorIds : undefined;
}
function mapSearchFacetParams(
filters: WorkOrderAdvancedFilters,
): Pick<
WorkOrderBoardSearchQueryParams,
| "severities"
| "rescheduled"
| "carriedOver"
| "addOn"
| "flagColors"
| "internalOnly"
| "hasUplift"
| "upliftStatuses"
> {
return {
severities: filters.severities?.size ? [...filters.severities] : undefined,
rescheduled: filters.rescheduled || undefined,
carriedOver: filters.carriedOver || undefined,
addOn: filters.addOn || undefined,
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
internalOnly: filters.internalOnly || undefined,
hasUplift: filters.hasUplift || undefined,
upliftStatuses:
filters.hasUplift && filters.upliftStatuses?.size ? [...filters.upliftStatuses] : undefined,
};
}
/** Build GET /board query from weekly filter bar. */
export function toBoardQueryParams(
bar: WorkOrderBarFilters,
@ -95,6 +152,31 @@ export function toBoardQueryParams(
};
}
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
datePreset: WorkOrderBoardSearchQueryParams["datePreset"];
dateFrom?: string;
dateTo?: string;
} {
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
return {
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
};
}
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
if (datePreset === "Custom") {
return {
datePreset,
dateFrom: filters.customFrom || undefined,
dateTo: filters.customTo || undefined,
};
}
return { datePreset };
}
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
export function toBoardSearchQueryParams(input: {
filters: WorkOrderAdvancedFilters;
@ -107,48 +189,35 @@ export function toBoardSearchQueryParams(input: {
vendorIdsByCompanyName?: Map<string, number>;
}): WorkOrderBoardSearchQueryParams {
const { filters } = input;
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
const date = resolveAdvancedSearchDate(filters);
const pageSize = Math.min(
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
BOARD_SEARCH_MAX_PAGE_SIZE,
);
const vendorIds: number[] = [];
if (input.vendorIdsByCompanyName && filters.vendorTechs.size > 0) {
for (const name of filters.vendorTechs) {
const id = input.vendorIdsByCompanyName.get(name);
if (typeof id === "number") {
vendorIds.push(id);
}
}
}
const typeFilters = mapWoTypeFilters(filters.types);
const trimmedSearch = input.search?.trim();
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
const params: WorkOrderBoardSearchQueryParams = {
return {
page: input.page ?? 0,
pageSize,
search:
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
datePreset,
search,
datePreset: date.datePreset,
dateFrom: date.dateFrom,
dateTo: date.dateTo,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
dispatchers: mapDispatchers(filters.dispatchers),
statuses: mapStatuses(filters.statuses),
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
vendorIds: vendorIds.length > 0 ? vendorIds : undefined,
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
docStatuses: mapDocStatuses(filters.docs),
...mapSearchFacetParams(filters),
sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc",
};
if (datePreset === "Custom") {
params.dateFrom = filters.customFrom || undefined;
params.dateTo = filters.customTo || undefined;
}
return params;
}
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
@ -175,3 +244,16 @@ export function buildBoardSearchParamsRecord(
export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue";
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
return {
page,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
dateTo: UNASSIGNED_QUEUE_DATE_TO,
};
}

View file

@ -102,6 +102,78 @@ export function filterByBarAndSearch(
});
}
export function isUnassignedBoardRow(row: WorkOrderTableRow): boolean {
if (TERMINAL_STATUSES.has(row.status)) {
return false;
}
return !row.dispatcherId;
}
export function partitionBoardRows(rows: WorkOrderTableRow[]): {
unassigned: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];
scheduled: WorkOrderTableRow[];
} {
const unassigned: WorkOrderTableRow[] = [];
const remaining: WorkOrderTableRow[] = [];
for (const row of rows) {
if (isUnassignedBoardRow(row)) {
unassigned.push(row);
} else {
remaining.push(row);
}
}
const { scheduled, unscheduled } = splitScheduledUnscheduled(remaining);
return { unassigned, unscheduled, scheduled };
}
export function rowIdSet(rows: WorkOrderTableRow[]): Set<string> {
return new Set(rows.map((row) => String(row.id)));
}
/** Weekly board rows win on id overlap so PATCH state stays authoritative. */
export function mergeWeeklyAndUnassignedQueue(
weekly: WorkOrderTableRow[],
queue: WorkOrderTableRow[],
): WorkOrderTableRow[] {
if (queue.length === 0) {
return weekly;
}
const seen = rowIdSet(weekly);
const extras = queue.filter((row) => !seen.has(String(row.id)));
return extras.length === 0 ? weekly : [...weekly, ...extras];
}
type ComposeBoardSectionsInput = {
displayRows: WorkOrderTableRow[];
weekRows: WorkOrderTableRow[];
weeklyIds: ReadonlySet<string>;
queueIds: ReadonlySet<string>;
};
/**
* Pin is every non-terminal unassigned row (including out-of-week).
* Day/Unscheduled groups stay week-scoped and never repeat pin ids.
*/
export function composeBoardSections(input: ComposeBoardSectionsInput): {
unassigned: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];
scheduled: WorkOrderTableRow[];
} {
const unassigned = input.displayRows.filter(isUnassignedBoardRow);
const pinIds = rowIdSet(unassigned);
const weekAssigned = input.weekRows.filter((row) => {
const id = String(row.id);
if (pinIds.has(id) || isUnassignedBoardRow(row)) {
return false;
}
return input.weeklyIds.has(id) || !input.queueIds.has(id);
});
return { unassigned, ...splitScheduledUnscheduled(weekAssigned) };
}
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
scheduled: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];

View file

@ -0,0 +1,130 @@
import type {
WorkOrderUplift,
UpliftSummary,
WorkOrderUpliftStatus,
} from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export function formatUpliftMoney(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) {
return "—";
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
}
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
pending: "Pending",
approved: "Approved",
auto_approved: "Auto",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Revoked",
};
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
return STATUS_LABELS[status];
}
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
if (!summary.hasUplift && summary.pendingCount <= 0) {
return null;
}
if (summary.pendingCount > 0) {
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
}
if (summary.primaryStatus) {
const label = formatUpliftStatusLabel(summary.primaryStatus);
if (summary.amount != null) {
return `${label} ${formatUpliftMoney(summary.amount)}`;
}
return label;
}
if (summary.hasUplift) {
return "Uplift";
}
return null;
}
export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
background: string;
color: string;
} {
switch (status) {
case "pending":
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
case "approved":
return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
case "auto_approved":
return { background: "var(--color-primary-tint)", color: "var(--color-primary)" };
case "rejected":
return { background: "#FEE2E2", color: "#B91C1C" };
case "cancelled":
case "revoked":
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
default:
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
}
}
export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
return status === "Completed" || status === "Canceled";
}
export function canOpenUpliftsDialog(
summary: UpliftSummary,
status: WorkOrderTableRow["status"],
): boolean {
if (summary.hasUplift || summary.pendingCount > 0) {
return true;
}
return !isWorkOrderUpliftsReadOnly(status);
}
export function canRevokeWorkOrderUplift(
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
currentUserId: string | number | null | undefined,
): boolean {
if (uplift.status !== "auto_approved") {
// SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
return false;
}
return (
currentUserId != null &&
currentUserId !== "" &&
uplift.requestedById != null &&
String(uplift.requestedById) === String(currentUserId)
);
}
export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
return status === "approved";
}
export const UPLIFT_STANDARD_ALLOWANCE_USD = 500;
export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000;
export function getUpliftAllowanceCap(workOrderType: string): number {
return workOrderType === "Emergency"
? UPLIFT_EMERGENCY_ALLOWANCE_USD
: UPLIFT_STANDARD_ALLOWANCE_USD;
}
export function hasOpenWorkOrderUplift(
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status">>,
): boolean {
return uplifts.some((uplift) => uplift.status === "pending");
}
export function getRemainingUpliftAllowance(
workOrderType: string,
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status" | "amount">>,
): number {
const consumed = uplifts
.filter((uplift) => uplift.status === "auto_approved")
.reduce((sum, uplift) => sum + uplift.amount, 0);
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
}

View file

@ -0,0 +1,148 @@
import type {
CompletionDocStatus,
WorkOrderUpliftSummary,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Company / vendor assignment — required for Completed (SH-183). */
company: string;
/** Technician is optional for closability; modeled separately from company. */
tech: string;
/** Work completed date (API CompletedDate). Empty until set. */
completedDate: string;
woNumber: string;
pm: string;
docStatus: CompletionDocStatus;
mediaCount: number;
type: WOType | "";
upliftSummary?: WorkOrderUpliftSummary | null;
hasPendingUplift?: boolean;
};
export const PENDING_UPLIFT_GAP = "Pending uplift approval";
/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */
export const UPLIFT_PENDING_COMPLETED_TITLE =
"This work order has an uplift pending approval and cannot be completed.";
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
export function isRealWoNumber(woNumber: string): boolean {
const trimmed = woNumber.trim();
if (!trimmed) {
return false;
}
return !/^SH/i.test(trimmed);
}
export function hasPendingUplift(
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
): boolean {
if (row.hasPendingUplift === true) {
return true;
}
return (row.upliftSummary?.pendingCount ?? 0) > 0;
}
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
}
function hasCompany(company: string): boolean {
return Boolean(company.trim());
}
function hasService(pm: string): boolean {
return Boolean(pm.trim());
}
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
return docStatus === "Yes" || docStatus === "NN";
}
/**
* Gaps that block Completed, in design hierarchy:
* fields → uplift pending → completion document / photos.
* (Scheduled-date gate is a separate design P1 — not in this module yet.)
*/
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
const gaps: string[] = [];
if (!hasDispatcher(row)) {
gaps.push("Dispatcher");
}
if (!hasCompany(row.company)) {
gaps.push("Company");
}
if (!row.completedDate.trim()) {
gaps.push("Completed Date");
}
if (!isRealWoNumber(row.woNumber)) {
gaps.push("WO number");
}
if (!hasService(row.pm)) {
gaps.push("Service");
}
// Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion.
// Missing uplift fields mean no pending evidence until the board contract supplies the count.
if (hasPendingUplift(row)) {
gaps.push(PENDING_UPLIFT_GAP);
}
if (row.type === "PM" || row.type === "Overdue") {
if (!hasCompletionDocument(row.docStatus)) {
gaps.push("Completion document");
}
}
if (row.type === "Emergency" || row.type === "Reactive") {
if (row.mediaCount < 1) {
gaps.push("At least 1 photo");
}
}
return gaps;
}
export function formatClosabilityMissingTitle(gaps: string[]): string {
if (gaps.includes(PENDING_UPLIFT_GAP)) {
return UPLIFT_PENDING_COMPLETED_TITLE;
}
return `Missing: ${gaps.join(", ")}`;
}
export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string };
/**
* Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this
* before opening generate flow and surface `reason` when blocked.
*
* Intentionally omits the "Completion document" closability gap — generate is how that gap is filled.
*/
export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult {
const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document");
if (gaps.length > 0) {
return { allowed: false, reason: formatClosabilityMissingTitle(gaps) };
}
return { allowed: true };
}
/** Map create-wizard draft onto the shared closability contract. */
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
return {
dispatcherId: draft.assigneeId,
dispatcherName: draft.assigneeName,
company: draft.vendorName,
tech: "",
completedDate: "",
woNumber: draft.woNumber,
pm: draft.pm,
docStatus: "",
mediaCount: 0,
type: draft.type,
};
}

View file

@ -17,6 +17,7 @@ export const queryKeys = {
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
byLocation: (locationId: string | number, search = "") =>
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
@ -25,6 +26,8 @@ export const queryKeys = {
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
uplifts: (workOrderId: string | number) =>
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
},
pmSchedules: {
all: ["pmSchedules"] as const,

View file

@ -4,3 +4,13 @@ export function getPrimaryUserRole(
): string {
return userRoles?.split(",")[0]?.trim() || fallback;
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
if (!userRoles) {
return false;
}
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes("admin");
}

View file

@ -156,6 +156,17 @@ function createClient(): QueryClient {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
/**
* SH-250: mirrors the app's real staleTime (src/lib/query/query-client.ts). The default
* test client uses staleTime 0, which silently masks cache-staleness defects on the
* reload path — the conflict recovery must not depend on an empty cache.
*/
function createCachingClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 5 * 60 * 1000 } },
});
}
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
@ -534,6 +545,41 @@ describe("useVendorRosterForm additive add flow (SH-250)", () => {
await waitFor(() => expect(result.current.name.field.value).toBe("Gateway Plumbing & Drain"));
});
it("reload bypasses the cache so the retry carries the fresh rowVersion", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
{ wrapper: makeWrapper(createCachingClient()) },
);
await act(async () => {
await result.current.selectCompany(company);
});
expect(rosterGet).toHaveBeenCalledTimes(1);
act(() => {
result.current.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
// The 409 never invalidates this key, and in the Add flow nothing else observes it.
// Without staleTime: 0 on the reload fetch, the cached rv-1 is served straight back
// and every retry 409s again.
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
await act(async () => {
result.current.reload();
});
await waitFor(() => expect(rosterGet).toHaveBeenCalledTimes(2));
act(() => {
result.current.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
);
});
it("does not regress the Edit Vendor reconcile path (mode update)", () => {
rosterQueryResult = {
data: rosterWithTechnicians,

View file

@ -73,6 +73,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,

View file

@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
}

View file

@ -0,0 +1,160 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 1,
woNumber: "1001",
site: "BK5",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Alex",
scheduledOn: "2026-07-15",
completedDate: "2026-07-20",
targetWeek: "2026-07-15",
dayGroup: "07/15",
dayLabel: "Tue",
location: "Lobby",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "7",
company: "Acme",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "In Progress",
docStatus: "Yes",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
isAddOn: false,
mediaCount: 1,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("StatusCell pending uplift closability", () => {
it("disables Completed with tooltip when uplift is pending", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ hasPendingUplift: true })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute(
"title",
"This work order has an uplift pending approval and cannot be completed.",
);
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
hasPendingUplift: true,
upliftSummary: { hasUplift: true, pendingCount: 1 },
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute(
"title",
"This work order has an uplift pending approval and cannot be completed.",
);
});
it("allows Completed when no pending uplift", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell wo={makeRow()} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("disables Completed when company is missing (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("allows Completed when technician is empty if company is set (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ tech: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
});

View file

@ -60,6 +60,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,

View file

@ -50,6 +50,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -0,0 +1,412 @@
import { useMemo, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
buildDayGroups,
composeBoardSections,
rowIdSet,
} from "@/domain/work-orders/utils/table-filter-utils";
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "24991",
site: "CLV2",
type: "PM",
severity: null,
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-06-02",
completedDate: "",
targetWeek: "",
dayGroup: "06/02",
dayLabel: "TUE · 06/02",
location: "Cleveland, OH",
locationId: "",
pm: "Backflow",
serviceNotes: "",
extraServices: [],
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "—",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
vendorId: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
function baseTableData(
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
): ReturnTypeOfUseWorkOrdersTableData {
return {
rows: [],
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
advActive: false,
advResults: [],
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unscheduled: [],
displayGroups: [],
searchActive: false,
noSearchResults: false,
isEmptyWeek: false,
todayKey: null,
draftDayKey: null,
draft: null,
setDraft: vi.fn(),
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unschedOpen: true,
setUnschedOpen: vi.fn(),
selectedId: null,
setSelectedId: vi.fn(),
selectedRow: null,
flashId: null,
triggerFlash: vi.fn(),
dragViz: null,
setDragViz: vi.fn(),
dragState: null,
patchRow: vi.fn(),
clearRowPatch: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
startDayDraft: vi.fn(),
cancelDraft: vi.fn(),
addRow: vi.fn(),
scopeTotalCount: 0,
totalCount: 0,
search: "",
...overrides,
} as ReturnTypeOfUseWorkOrdersTableData;
}
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
function UnassignedAssignHarness({
initialRow,
patchBoardField,
}: {
initialRow: WorkOrderTableRow;
patchBoardField: (
id: string | number,
payload: { field: string; value?: string | null },
) => Promise<WorkOrderTableRow>;
}) {
const [rows, setRows] = useState([initialRow]);
const weeklyIds = rowIdSet(rows);
const sections = useMemo(
() =>
composeBoardSections({
displayRows: rows,
weekRows: rows,
weeklyIds,
queueIds: weeklyIds,
}),
[rows, weeklyIds],
);
const tableData = baseTableData({
unassigned: sections.unassigned,
unscheduled: sections.unscheduled,
displayGroups: buildDayGroups(sections.scheduled),
});
return (
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
<td>
<AssignedToCell
dispatcherId={row.dispatcherId}
users={[DISPATCHER]}
highlight={!row.dispatcherId}
onSave={async (id, name) => {
const ops = expandTablePatchToBoardFieldOps({
dispatcherId: id,
dispatcherName: name,
});
const patched = await patchBoardField(row.id, ops[0]!);
setRows((current) =>
current.map((item) =>
String(item.id) === String(row.id)
? { ...patched, dispatcherId: id, dispatcherName: name }
: item,
),
);
}}
/>
</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>
);
}
describe("WoTableBody Unassigned queue (SH-165)", () => {
it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
const undatedUnassigned = makeRow({
id: "us1",
woNumber: "25021",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const undatedAssigned = makeRow({
id: "us-assigned",
woNumber: "25022",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const datedAssigned = makeRow({
id: "day-assigned",
woNumber: "25023",
dispatcherId: "sarah",
dispatcherName: "Sarah",
});
const tableData = baseTableData({
unassigned: [datedUnassigned, undatedUnassigned],
unscheduled: [undatedAssigned],
displayGroups: [
{
key: "06/02",
label: "TUE · 06/02",
date: "2026-06-02",
items: [datedAssigned],
},
],
});
render(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
const unassignedHeader = screen.getByRole("button", {
name: "Unassigned, 2 work orders, expanded",
});
const unscheduledHeader = screen.getByRole("button", {
name: "Unscheduled, 1 work order, expanded",
});
const dayLabel = screen.getByText("TUE · 06/02");
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
});
it("collapses and expands the Unassigned section via the disclosure control", async () => {
const user = userEvent.setup();
const setUnassignedOpen = vi.fn();
const tableData = baseTableData({
unassigned: [makeRow({ id: "u1", woNumber: "24991" })],
setUnassignedOpen,
});
render(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
});
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
const outOfWeek = makeRow({
id: "out-week",
woNumber: "26001",
scheduledOn: "2026-07-15",
dayGroup: "07/15",
dayLabel: "WED · 07/15",
});
render(
<table>
<WoTableBody
tableData={baseTableData({ unassigned: [outOfWeek] })}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
});
it("distinguishes a still-loading queue from an empty one", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: true })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument();
expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument();
});
it("announces an empty queue only once loading has settled", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: false })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
});
it("keeps the Unassigned pin visible when the queue query fails", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({
unassignedError: new Error("queue unavailable"),
})}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
});
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
render(
<table>
<WoTableBody
tableData={baseTableData()}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
).toBeInTheDocument();
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument();
});
it("removes a pinned row immediately after assigning through the board PATCH path", async () => {
const user = userEvent.setup();
const pinned = makeRow({
id: "u-pin",
woNumber: "24991",
dispatcherId: "",
dispatcherName: "",
rowVersion: "AAAAAAA=",
});
const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => {
expect(payload.field).toBe("assignTo");
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
});
render(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Select/ }));
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
expect(patchBoardField).toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
).toBeInTheDocument();
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
});
});

View file

@ -54,6 +54,7 @@ const unscheduledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
const handlers: WoTableRowHandlers = {
@ -76,6 +77,11 @@ const handlers: WoTableRowHandlers = {
function renderUnscheduledHarness() {
const tableData = {
advActive: false,
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unscheduled: [unscheduledRow],
unschedOpen: true,
draftDayKey: null,

View file

@ -93,6 +93,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -0,0 +1,177 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
import { renderWithProviders } from "@/test/test-utils";
const pendingUplift: WorkOrderUplift = {
id: 1,
status: "pending",
amount: 450,
notes: "Extra work",
requestedAt: "2026-07-20T10:00:00Z",
requestedById: "vendor-1",
requestedByName: "Vendor",
decidedAt: "",
decidedByName: "",
decisionNote: "",
};
const mockState = vi.hoisted(() => ({
uplifts: [] as WorkOrderUplift[],
}));
const baseRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Alex",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",
location: "Chicago Loop",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "1",
company: "Vendor Co",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 },
};
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({
data: mockState.uplifts,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
}));
describe("UpliftCell affordances", () => {
it("renders pending pill and opens handler when onOpen is provided", () => {
const onOpen = vi.fn();
renderWithProviders(
<UpliftCell summary={{ hasUplift: true, pendingCount: 1 }} onOpen={onOpen} />,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /1 pending/i }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("shows read-only dash when onOpen is omitted", () => {
renderWithProviders(<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} />, {
withAuth: false,
});
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
});
});
describe("WorkOrderUpliftsDialog affordances", () => {
it("hides create form when a pending request already exists", () => {
mockState.uplifts = [pendingUplift];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
});
expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument();
expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
});
it("shows create form with remaining allowance when no pending request exists", () => {
mockState.uplifts = [];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
});
expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument();
expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument();
});
it("hides create and cancel actions when work order is completed", () => {
mockState.uplifts = [pendingUplift];
renderWithProviders(
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
{ withAuth: true },
);
expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument();
});
});
describe("WorkOrderUpliftListItem revoke affordances", () => {
it("hides Revoke for admin-approved uplifts on the Work Order surface", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={{ ...pendingUplift, status: "approved" }}
readOnly={false}
currentUserId="admin-1"
onRevoke={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
});
it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }}
readOnly={false}
currentUserId="dispatcher-1"
onRevoke={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
describe("WorkOrdersAdvancedFiltersBanner count semantics", () => {
it("labels the banner with search totalCount, not the current page length", () => {
render(
<WorkOrdersAdvancedFiltersBanner
applied={{
...EMPTY_ADVANCED_FILTERS,
severities: new Set([1]),
hasUplift: true,
}}
totalCount={150}
sites={[]}
users={[]}
onClearChip={() => undefined}
onClearAll={() => undefined}
/>,
);
expect(screen.getByText("150 work orders")).toBeInTheDocument();
});
});

View file

@ -2,6 +2,10 @@ import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
BOARD_SEARCH_MAX_PAGE_SIZE,
toBoardSearchQueryParams,
} from "@/domain/work-orders/utils/board-query-params";
import { renderWithProviders } from "@/test/test-utils";
const setAdvOpen = vi.fn();
@ -55,8 +59,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
useWorkOrdersTableData: () => ({
rows: [],
totalCount: 0,
scopeTotalCount: 0,
totalCount: advApplied ? 42 : 0,
scopeTotalCount: advApplied ? 42 : 0,
isFetching: false,
isLoading: false,
setSelectedId: vi.fn(),
@ -102,10 +106,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
WoTable: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({
WorkOrdersFilterBar: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
MediaUploadModal: () => null,
}));
@ -158,4 +158,113 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
);
expect(clearAdvancedFilters).toHaveBeenCalled();
});
it("applies Severity and Has uplift through the public Advanced Filters sheet", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /Severity/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /SEV 1/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Uplift/i }));
fireEvent.click(within(sheet).getByLabelText(/Has uplift/i));
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.severities.has(1)).toBe(true);
expect(applied.hasUplift).toBe(true);
expect(applied.upliftStatuses.has("pending")).toBe(true);
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.page).toBe(0);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.severities).toEqual([1]);
expect(params.hasUplift).toBe(true);
expect(params.upliftStatuses).toEqual(["pending"]);
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe("2000-01-01");
expect(params.dateTo).toBe("2099-12-31");
});
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /Indicators/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /Work Order Number/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /Internal numbers only/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.rescheduled).toBe(true);
expect(applied.carriedOver).toBe(true);
expect(applied.addOn).toBe(true);
expect(applied.flagColors.has("#EF4444")).toBe(true);
expect(applied.internalOnly).toBe(true);
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.page).toBe(0);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.rescheduled).toBe(true);
expect(params.carriedOver).toBe(true);
expect(params.addOn).toBe(true);
expect(params.flagColors).toEqual(["#EF4444"]);
expect(params.internalOnly).toBe(true);
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe("2000-01-01");
expect(params.dateTo).toBe("2099-12-31");
});
it("disables dispatcher, week, and type bar while advanced filters are applied", () => {
advApplied = { ...EMPTY_ADVANCED_FILTERS, rescheduled: true };
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
expect(screen.getByText("42 work orders")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
expect(clearAdvancedFilters).toHaveBeenCalled();
view.rerender(<WorkOrdersListPage />);
expect(screen.queryByText("42 work orders")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
expect(screen.getByRole("button", { name: "All" })).toBeEnabled();
expect(screen.getByRole("button", { name: "PM" })).toBeEnabled();
});
});

View file

@ -112,6 +112,7 @@ const canceledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({

View file

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params";
const searchBoard = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
searchBoard: (...args: unknown[]) => searchBoard(...args),
},
}));
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
return {
items,
scheduled: [],
unscheduled: [],
totalCount,
returnedCount: items.length,
page: 0,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
};
}
describe("fetchUnassignedBoardRows", () => {
beforeEach(() => {
searchBoard.mockReset();
});
it("pages until totalCount is exhausted", async () => {
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
id: `a-${index}`,
}));
const second = [{ id: "b-0" }];
searchBoard
.mockResolvedValueOnce(pageResult(first, 101))
.mockResolvedValueOnce(pageResult(second, 101));
const rows = await fetchUnassignedBoardRows();
expect(searchBoard).toHaveBeenCalledTimes(2);
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
page: 0,
dispatchers: ["__unassigned__"],
datePreset: "Custom",
});
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
expect(rows).toHaveLength(101);
});
});

View file

@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
}));
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
describe("workOrderUpliftsApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
});
it("propagates a real API list failure instead of returning mock uplifts", async () => {
const error = new Error("Unauthorized");
apiGet.mockRejectedValueOnce(error);
await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error);
});
});

View file

@ -174,4 +174,64 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
});
it("maps uplift summary and pending flag from API payload", () => {
const row = mapWorkOrderTableRow({
id: 303,
upliftSummary: {
hasUplift: true,
pendingCount: 2,
primaryStatus: "pending",
},
});
expect(row.upliftSummary).toEqual({
hasUplift: true,
pendingCount: 2,
primaryStatus: "pending",
});
expect(row.hasPendingUplift).toBe(true);
});
it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => {
const pending = mapWorkOrderTableRow({
id: 305,
pendingUpliftCount: 1,
});
expect(pending.hasPendingUplift).toBe(true);
expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 });
const clear = mapWorkOrderTableRow({
id: 306,
PendingUpliftCount: 0,
});
expect(clear.hasPendingUplift).toBe(false);
expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 });
});
it("does not copy work-order status into uplift primaryStatus on flat board rows", () => {
const row = mapWorkOrderTableRow({
id: 308,
pendingUpliftCount: 1,
status: "In Progress",
Status: "In Progress",
});
expect(row.upliftSummary?.primaryStatus).toBeUndefined();
});
it("leaves hasPendingUplift undefined when board count is absent", () => {
const row = mapWorkOrderTableRow({ id: 307 });
expect(row.hasPendingUplift).toBeUndefined();
expect(row.upliftSummary).toBeUndefined();
});
it("maps hasPendingUplift from top-level API flag", () => {
const row = mapWorkOrderTableRow({
id: 304,
hasPendingUplift: true,
});
expect(row.hasPendingUplift).toBe(true);
expect(row.upliftSummary).toBeUndefined();
});
});

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
mapUpliftSummary,
mapWorkOrderUplift,
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
describe("mapUpliftSummary", () => {
it("maps nested PascalCase upliftSummary from board row payload", () => {
const result = mapUpliftSummary({
id: 10,
UpliftSummary: {
HasUplift: true,
PendingCount: 2,
PrimaryStatus: "Pending",
Amount: 500,
},
});
expect(result).toEqual({
hasUplift: true,
pendingCount: 2,
primaryStatus: "pending",
amount: 500,
});
});
it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => {
const result = mapUpliftSummary({
pendingUpliftCount: 1,
});
expect(result.hasUplift).toBe(true);
expect(result.pendingCount).toBe(1);
});
it("does not treat work-order status as uplift primaryStatus when nested summary is absent", () => {
const result = mapUpliftSummary({
status: "Cancelled",
Status: "Cancelled",
hasUplift: false,
});
expect(result.hasUplift).toBe(false);
expect(result.primaryStatus).toBeUndefined();
expect(getUpliftCellLabel(result)).toBeNull();
});
it("normalizes auto-approved and canceled status aliases", () => {
expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved");
expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled");
});
it("maps requester identity for revoke ownership checks", () => {
expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe(
"dispatcher-1",
);
});
});
describe("uplift display utils", () => {
it("builds pending pill label and style", () => {
const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const };
expect(getUpliftCellLabel(summary)).toBe("1 Pending");
expect(getUpliftStatusPillStyle("pending")).toEqual({
background: "var(--status-pending-bg)",
color: "var(--status-pending-text)",
});
});
it("includes amount in non-pending primary status label", () => {
expect(
getUpliftCellLabel({
hasUplift: true,
pendingCount: 0,
primaryStatus: "approved",
amount: 1200,
}),
).toBe("Approved $1,200");
});
it("hides Pending/Cancelled when hasUplift is false", () => {
expect(
getUpliftCellLabel({
hasUplift: false,
pendingCount: 0,
primaryStatus: "pending",
}),
).toBeNull();
expect(
getUpliftCellLabel({
hasUplift: false,
pendingCount: 0,
primaryStatus: "cancelled",
}),
).toBeNull();
});
});

View file

@ -98,7 +98,8 @@ const BASE_ROW: WorkOrderTableRow = {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
upliftSummary: { hasUplift: false, pendingCount: 0 },
} as WorkOrderTableRow;
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState;

View file

@ -100,4 +100,46 @@ describe("clearAdvancedFilterGroup", () => {
const next = clearAdvancedFilterGroup(withSites("1"), "sites");
expect(next).toBeNull();
});
it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
severities: new Set([1, 2] as const),
rescheduled: true,
flagColors: new Set(["#EF4444"]),
internalOnly: true,
hasUplift: true,
upliftStatuses: new Set(["pending" as const]),
};
const chips = buildAdvancedFilterChips(filters);
expect(chips.some((c) => c.key === "severity" && c.value === "SEV 1, SEV 2")).toBe(true);
expect(chips.some((c) => c.key === "rescheduled" && c.value === "Rescheduled")).toBe(true);
expect(chips.some((c) => c.key === "flags" && c.value === "Red")).toBe(true);
expect(chips.some((c) => c.key === "internal" && c.value === "Internal (SH)")).toBe(true);
expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true);
});
it("clears uplift chip and drops hasUplift with statuses", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
hasUplift: true,
upliftStatuses: new Set(["pending" as const, "approved" as const]),
};
const next = clearAdvancedFilterGroup(filters, "uplift");
expect(next).toBeNull();
});
it("clears indicator chip without affecting other groups", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
rescheduled: true,
severities: new Set([3] as const),
};
const next = clearAdvancedFilterGroup(filters, "rescheduled");
expect(next?.rescheduled).toBe(false);
expect(next?.severities.has(3)).toBe(true);
});
});

View file

@ -7,6 +7,9 @@ import {
buildBoardSearchParamsRecord,
toBoardQueryParams,
toBoardSearchQueryParams,
toUnassignedQueueQueryParams,
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params";
describe("toBoardQueryParams", () => {
@ -65,7 +68,9 @@ describe("toBoardSearchQueryParams", () => {
});
expect(params.page).toBe(0);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.datePreset).toBe("ThisWeek");
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe("2000-01-01");
expect(params.dateTo).toBe("2099-12-31");
});
it("maps date presets to PascalCase API values", () => {
@ -96,8 +101,9 @@ describe("toBoardSearchQueryParams", () => {
customTo: "2026-06-30",
},
});
expect(thisWeek.dateFrom).toBeUndefined();
expect(thisWeek.dateTo).toBeUndefined();
expect(thisWeek.datePreset).toBe("Custom");
expect(thisWeek.dateFrom).toBe("2000-01-01");
expect(thisWeek.dateTo).toBe("2099-12-31");
});
it("maps Overdue-only advanced filter to overdue=true without types", () => {
@ -147,6 +153,50 @@ describe("toBoardSearchQueryParams", () => {
expect(params.pmTypes).toBeUndefined();
expect(params.vendorIds).toBeUndefined();
expect(params.docStatuses).toBeUndefined();
expect(params.severities).toBeUndefined();
expect(params.rescheduled).toBeUndefined();
expect(params.carriedOver).toBeUndefined();
expect(params.addOn).toBeUndefined();
expect(params.flagColors).toBeUndefined();
expect(params.internalOnly).toBeUndefined();
expect(params.hasUplift).toBeUndefined();
expect(params.upliftStatuses).toBeUndefined();
});
it("maps severity and uplift facets onto board search params", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
severities: new Set([1, 3, 5]),
rescheduled: true,
carriedOver: true,
addOn: true,
flagColors: new Set(["#EF4444", "#3B82F6"]),
internalOnly: true,
hasUplift: true,
upliftStatuses: new Set(["pending", "approved"]),
},
});
expect(params.severities).toEqual([1, 3, 5]);
expect(params.rescheduled).toBe(true);
expect(params.carriedOver).toBe(true);
expect(params.addOn).toBe(true);
expect(params.flagColors).toEqual(["#EF4444", "#3B82F6"]);
expect(params.internalOnly).toBe(true);
expect(params.hasUplift).toBe(true);
expect(params.upliftStatuses).toEqual(["pending", "approved"]);
});
it("omits upliftStatuses when hasUplift is false", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
hasUplift: false,
upliftStatuses: new Set(["pending"]),
},
});
expect(params.hasUplift).toBeUndefined();
expect(params.upliftStatuses).toBeUndefined();
});
it("caps pageSize at BOARD_SEARCH_MAX_PAGE_SIZE", () => {
@ -174,3 +224,21 @@ describe("buildBoardSearchParamsRecord", () => {
expect(Object.keys(record)).not.toContain("types[]");
});
});
describe("toUnassignedQueueQueryParams", () => {
it("requests unassigned dispatchers with a Custom range, not the weekly board window", () => {
const params = toUnassignedQueueQueryParams(2);
const record = buildBoardSearchParamsRecord(params);
expect(params.page).toBe(2);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.dispatchers).toEqual(["__unassigned__"]);
expect(params.statuses).not.toContain(7);
expect(params.statuses).not.toContain(9);
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe(UNASSIGNED_QUEUE_DATE_FROM);
expect(params.dateTo).toBe(UNASSIGNED_QUEUE_DATE_TO);
expect(record).not.toHaveProperty("weekStart");
expect(record).not.toHaveProperty("weekEnd");
});
});

View file

@ -4,6 +4,7 @@ import {
isSlideOverDraftDirty,
SLIDE_OVER_EDIT_KEYS,
} from "@/domain/work-orders/utils/slide-over-draft";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
@ -52,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}

View file

@ -74,6 +74,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -1,11 +1,18 @@
import { describe, expect, it } from "vitest";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import {
buildDayGroups,
composeBoardSections,
computeIsPastDue,
enrichTableRow,
filterByBarAndSearch,
isUnassignedBoardRow,
matchesAdvancedFilters,
mergeWeeklyAndUnassignedQueue,
partitionBoardRows,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
@ -63,6 +70,19 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "AQAAAAAAAAA=",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}
function bar(
weekMonday: string,
overrides: Partial<WorkOrderBarFilters> = {},
): WorkOrderBarFilters {
return {
weekMonday,
dispatcherIds: new Set(),
type: "All",
...overrides,
};
}
@ -76,6 +96,162 @@ describe("deriveDayFields", () => {
});
});
describe("filterByBarAndSearch", () => {
const weekMonday = "2026-06-01";
it("keeps undated rows without targetWeek for any active week", () => {
const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" });
const ids = filterByBarAndSearch([undated], bar(weekMonday), "").map((row) => row.id);
expect(ids).toEqual(["us1"]);
});
it("excludes undated rows whose targetWeek is outside the active week", () => {
const undatedOtherTarget = makeRow({
id: "tw1",
scheduledOn: "",
targetWeek: "2026-07-06",
});
const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map(
(row) => row.id,
);
expect(ids).toEqual([]);
});
it("excludes dated rows outside the active week", () => {
const inWeek = makeRow({
id: "in",
scheduledOn: "2026-06-03",
...deriveDayFields("2026-06-03"),
});
const outWeek = makeRow({
id: "out",
scheduledOn: "2026-06-15",
...deriveDayFields("2026-06-15"),
});
const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id);
expect(ids).toEqual(["in"]);
});
});
describe("isUnassignedBoardRow", () => {
it("treats empty dispatcher as unassigned unless terminal", () => {
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "" }))).toBe(true);
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "u1" }))).toBe(false);
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "", status: "Completed" }))).toBe(false);
});
});
describe("partitionBoardRows", () => {
it("pins unassigned rows first and keeps assigned undated rows in Unscheduled", () => {
const datedUnassigned = makeRow({
id: "u-dated",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-06-02",
...deriveDayFields("2026-06-02"),
});
const undatedUnassigned = makeRow({
id: "us-undated",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const undatedAssigned = makeRow({
id: "us-assigned",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const datedAssigned = makeRow({ id: "day-assigned", dispatcherId: "sarah" });
const result = partitionBoardRows([
datedUnassigned,
undatedUnassigned,
undatedAssigned,
datedAssigned,
]);
expect(result.unassigned.map((row) => row.id)).toEqual(["u-dated", "us-undated"]);
expect(result.unscheduled.map((row) => row.id)).toEqual(["us-assigned"]);
expect(result.scheduled.map((row) => row.id)).toEqual(["day-assigned"]);
const allIds = [...result.unassigned, ...result.unscheduled, ...result.scheduled].map(
(row) => row.id,
);
expect(new Set(allIds).size).toBe(allIds.length);
});
});
describe("mergeWeeklyAndUnassignedQueue", () => {
it("keeps the weekly row when the same id is also in the queue", () => {
const weekly = makeRow({ id: "dup", dispatcherName: "Weekly" });
const queue = makeRow({ id: "dup", dispatcherName: "Queue" });
const extra = makeRow({ id: "out", dispatcherId: "", dispatcherName: "" });
const merged = mergeWeeklyAndUnassignedQueue([weekly], [queue, extra]);
expect(merged.map((row) => row.id)).toEqual(["dup", "out"]);
expect(merged[0]?.dispatcherName).toBe("Weekly");
});
});
describe("composeBoardSections", () => {
it("pins a scheduled unassigned WO that is outside the selected week", () => {
const inWeekAssigned = makeRow({
id: "in-week",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "2026-06-03",
...deriveDayFields("2026-06-03"),
});
const outOfWeekUnassigned = makeRow({
id: "out-week",
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-07-15",
...deriveDayFields("2026-07-15"),
});
const result = composeBoardSections({
displayRows: [inWeekAssigned, outOfWeekUnassigned],
weekRows: [inWeekAssigned],
weeklyIds: new Set(["in-week"]),
queueIds: new Set(["out-week"]),
});
expect(result.unassigned.map((row) => row.id)).toEqual(["out-week"]);
expect(result.scheduled.map((row) => row.id)).toEqual(["in-week"]);
expect(result.unscheduled).toEqual([]);
});
it("does not place an assigned leftover queue row into the current week groups", () => {
const assignedOutOfWeek = makeRow({
id: "was-queue",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "2026-07-15",
...deriveDayFields("2026-07-15"),
});
const result = composeBoardSections({
displayRows: [assignedOutOfWeek],
weekRows: [assignedOutOfWeek],
weeklyIds: new Set(),
queueIds: new Set(["was-queue"]),
});
expect(result.unassigned).toEqual([]);
expect(result.scheduled).toEqual([]);
expect(result.unscheduled).toEqual([]);
});
});
describe("splitScheduledUnscheduled", () => {
it("splits rows without scheduledOn into unscheduled", () => {
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
canRevokeWorkOrderUplift,
getRemainingUpliftAllowance,
hasOpenWorkOrderUplift,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
describe("canRevokeWorkOrderUplift", () => {
it("allows a dispatcher to revoke their own auto-approved uplift", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"dispatcher-1",
),
).toBe(true);
});
it("rejects a dispatcher revoking another user's auto-approved uplift", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "other-dispatcher" },
"dispatcher-1",
),
).toBe(false);
});
it("rejects admin revoking auto-approved uplift they did not request", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"admin-user",
),
).toBe(false);
});
it("allows admin to revoke their own auto-approved uplift as the requester", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "admin-user" },
"admin-user",
),
).toBe(true);
});
it("rejects approved revoke on the Work Order surface (SH-214)", () => {
const uplift = { status: "approved" as const, requestedById: "dispatcher-1" };
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false);
expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false);
});
it("rejects revoke for other statuses", () => {
expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false);
expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false);
});
});
describe("upliftRevokeRequiresReason", () => {
it("requires reason only for approved uplifts", () => {
expect(upliftRevokeRequiresReason("approved")).toBe(true);
expect(upliftRevokeRequiresReason("auto_approved")).toBe(false);
});
});
describe("SH-196 allowance helpers", () => {
it("uses a $500 cap for non-emergency work orders", () => {
expect(getRemainingUpliftAllowance("PM", [])).toBe(500);
expect(getRemainingUpliftAllowance("PM", [{ status: "auto_approved", amount: 400 }])).toBe(100);
});
it("uses a $5,000 cap for emergency work orders", () => {
expect(getRemainingUpliftAllowance("Emergency", [])).toBe(5000);
});
it("ignores revoked and pending amounts when computing remaining allowance", () => {
expect(
getRemainingUpliftAllowance("PM", [
{ status: "auto_approved", amount: 200 },
{ status: "revoked", amount: 300 },
{ status: "pending", amount: 400 },
]),
).toBe(300);
});
it("detects an open pending request", () => {
expect(hasOpenWorkOrderUplift([{ status: "pending" }])).toBe(true);
expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false);
});
});

View file

@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import {
PENDING_UPLIFT_GAP,
UPLIFT_PENDING_COMPLETED_TITLE,
canGenerateCompDoc,
formatClosabilityMissingTitle,
getClosabilityGaps,
hasPendingUplift,
isRealWoNumber,
} from "@/domain/work-orders/utils/wo-closability";
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
return {
dispatcherId: "u1",
dispatcherName: "Alex",
company: "Acme HVAC",
tech: "Sam",
completedDate: "2026-07-01",
woNumber: "1001",
pm: "HVAC",
docStatus: "Yes",
mediaCount: 1,
type: "PM",
hasPendingUplift: false,
...overrides,
};
}
describe("isRealWoNumber", () => {
it("rejects empty and SH-prefixed provisional numbers", () => {
expect(isRealWoNumber("")).toBe(false);
expect(isRealWoNumber("SH-9")).toBe(false);
expect(isRealWoNumber("sh123")).toBe(false);
expect(isRealWoNumber("98765")).toBe(true);
});
});
describe("hasPendingUplift", () => {
it("is true when hasPendingUplift flag is set", () => {
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
});
it("is true when upliftSummary.pendingCount is positive", () => {
expect(
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })),
).toBe(true);
});
it("is false when uplift is explicitly not pending", () => {
expect(hasPendingUplift(makeInput())).toBe(false);
expect(
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })),
).toBe(false);
});
});
describe("getClosabilityGaps", () => {
it("returns empty when all gates are satisfied", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("allows company without technician (SH-183)", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician");
});
it("blocks Completed when company is missing even if technician is assigned", () => {
expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]);
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
});
it("lists field gaps before uplift and document gaps", () => {
expect(
getClosabilityGaps(
makeInput({
dispatcherId: "",
dispatcherName: "",
company: " ",
tech: " ",
completedDate: "",
woNumber: "SH-1",
pm: "",
hasPendingUplift: true,
docStatus: "No",
type: "PM",
}),
),
).toEqual([
"Dispatcher",
"Company",
"Completed Date",
"WO number",
"Service",
PENDING_UPLIFT_GAP,
"Completion document",
]);
});
it("blocks Completed when uplift is pending via summary", () => {
expect(
getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })),
).toEqual([PENDING_UPLIFT_GAP]);
});
it("blocks Completed when hasPendingUplift is true", () => {
expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]);
});
it("allows Completed when uplift status fields are absent (no pending evidence)", () => {
expect(
getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })),
).toEqual([]);
});
it("requires a photo for Emergency / Reactive", () => {
expect(
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
).toEqual(["At least 1 photo"]);
});
});
describe("formatClosabilityMissingTitle", () => {
it("uses design Completed title when only uplift is missing", () => {
expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe(
UPLIFT_PENDING_COMPLETED_TITLE,
);
});
it("uses design Completed title when pending uplift is among mixed gaps", () => {
expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe(
UPLIFT_PENDING_COMPLETED_TITLE,
);
});
it("joins multiple non-uplift gaps with Missing prefix", () => {
expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe(
"Missing: Dispatcher, Company",
);
});
});
describe("canGenerateCompDoc", () => {
it("allows generation when no closability gaps", () => {
expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true });
});
it("allows generation when only the completion document is missing", () => {
expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true });
});
it("blocks generation with design reason when only pending uplift exists", () => {
expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({
allowed: false,
reason: UPLIFT_PENDING_COMPLETED_TITLE,
});
});
});

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
expect(isAdminUser("Admin")).toBe(true);
expect(isAdminUser("Dispatcher,Admin")).toBe(true);
expect(isAdminUser(" admin ")).toBe(true);
});
it("returns false for non-admin roles", () => {
expect(isAdminUser("Dispatcher")).toBe(false);
expect(isAdminUser(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,39 @@
# PR4 — Pending uplift close gate (+ SH-114 closability union)
## Summary
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
- Completed tooltip uses design copy when the **only** gap is pending uplift.
- Maps optional `completedDate` on board rows for the Completed Date gate.
## Jira
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
## Comp Doc Generate
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
## Out of scope
- Full uplift CRUD (PR3)
- Advanced filter facets for uplift
- Scheduled-requires-date gate (design P1)
- COL_HELP tooltips (P1)
## Test plan
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
- [ ] Row fully closable — Completed selectable.
- [ ] `canGenerateCompDoc` blocked when gaps exist.
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
## Base
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.