Merge branch 'dev' into feat/sh-250-fe-additive-add

This commit is contained in:
Alexandre Brandizzi 2026-08-19 10:28:04 -03:00 • committed by GitHub
commit a71407b426
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 4566 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

@ -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

@ -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.