diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index 0051e53a..eea2a0bd 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -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` diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index f43d1e07..d175e6c5 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -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: { diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx new file mode 100644 index 00000000..06e2104e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx @@ -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>; +}; + +export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) { + return ( + +
+ {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
+ {draft.dateRange === "custom" && ( +
+ setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
+ )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 9bfed9a1..88ba6ca3 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -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 (
- -
- {DATE_RANGE_OPTIONS.map((option) => ( - setDraft((prev) => ({ ...prev, dateRange: option.key }))} - /> - ))} -
- {draft.dateRange === "custom" && ( -
- setDraft((prev) => ({ ...prev, customFrom: value }))} - /> - setDraft((prev) => ({ ...prev, customTo: value }))} - /> -
- )} -
+ + + + + + + ({ value: pmType, label: pmType }))} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx new file mode 100644 index 00000000..559004c8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -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>; + 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 ( + + ); +} + +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 ( + <> + +
+ setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))} + className="inline-flex items-center gap-1.5" + /> +
+
+ + + ({ 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) => } + /> + + + + + + + + + {Boolean(draft.hasUplift) && ( +
+ {UPLIFT_FILTER_STATUSES.map((status) => ( + toggleUpliftStatus(status)} + /> + ))} +
+ )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx new file mode 100644 index 00000000..05e6d9c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx @@ -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>; +}; + +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 ( + <> +
+ 0} + onClick={() => + setDraft((prev) => ({ + ...prev, + severities: + prev.severities.size > 0 + ? new Set() + : new Set(WORK_ORDER_SEVERITIES), + })) + } + /> +
+
+ {WORK_ORDER_SEVERITIES.map((level) => { + const meta = SEVERITY_LEVELS[level]; + const color = SEVERITY_TOKEN_COLOR[meta.token]; + const selected = draft.severities.has(level); + return ( + + ); + })} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx index 9db6ca98..6bee3519 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -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({ 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 (
setOffset((current) => current - 1)} aria-label="Previous weeks" > @@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) { onChange(week.mondayIso)} > {week.label} @@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) { })} setOffset((current) => current + 1)} aria-label="Next weeks" > diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx index 3810f8ec..03d89520 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx @@ -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 ( {OPTIONS.map((option) => ( - onChange(option)}> + onChange(option)} + > {option} ))} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx index d12819f5..3a1c114f 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -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 ( -
+
- - + +
{resultLabel}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index 92557ea2..42a1bf93 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig />
+ {filtered.map((d) => ( + + 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" }, + }} + > +
+ +
+ + This work order is past due. Update Schedule On before the status can be updated. + + +
+
+
+ + ); +} + export function StatusCell({ wo, - onChangeStatus, - onOpenSlideOver, - onPastDueAttempt, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); + const closabilityGaps = rowClosabilityGaps(wo); if (wo.isPastDue) { return ( - <> - - - 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" }, - }} - > -
- - -
- - This work order is past due. Update Schedule On before the status can be updated. - - - -
-
-
- + ); } @@ -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({ ) : ( — )} - @@ -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) => ( - - ))} + return ( + + ); + })} ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx new file mode 100644 index 00000000..6f4c4558 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx @@ -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 ? ( + {label} + ) : ( + — + ); + } + + const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus; + const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null; + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index c9626b3e..1a6b51c5 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index b859994f..7e51c960 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -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 {tableData.advResults.map((row) => renderRow(row))}; } + 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 ( + tableData.setUnassignedOpen((v) => !v)} + /> + {tableData.unassignedOpen && ( + <> + {renderInlineRow(UNASSIGNED_DAY_KEY)} + {Boolean(tableData.unassignedError) && ( + + + + {tableData.unassignedError?.message ?? "Unable to load unassigned work orders."} + + + + )} + {showUnassignedLoading && ( + + + + Loading unassigned work orders… + + + + )} + {showUnassignedEmpty && ( + + + + No unassigned work orders. + + + + )} + {unassignedRows.map((row) => renderRow(row))} + + )} {showUnscheduled && ( <> = { + 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 ( + + +
+ + {Boolean(onAddWo) && ( + + )} +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index e9c4f1ee..12194252 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -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; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 9746bd65..a022f6fa 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -229,6 +229,7 @@ export function WoTableInlineRowCells({ + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 679a0c3b..83e94733 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -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); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 7974f536..7c7c2d50 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -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)} /> + e.stopPropagation()}> + handlers.onOpenUplifts!(row) + : undefined + } + /> + void; onEdit: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: Partial) => void; onScheduleDate: (id: string | number, date: string) => void; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx index 6b22e968..a730c7f5 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -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 ( - - -
- - - {label} - - - {count} work order{count !== 1 ? "s" : ""} - - - - {collapsed ? : } - -
- - + onToggleCollapse?.()} + /> + ); + } + + if (variant === "unscheduled") { + return ( + onToggleCollapse?.()} + onAddWo={onAddWo} + /> ); } @@ -125,18 +98,17 @@ export function WoTableSectionHeader({ Today )} - + {Boolean(onAddWo) && ( + + )}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index c733ce89..d783a944 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx index a38542d6..6bb73f82 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx @@ -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; - onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; - onDateCompletedChange: (date: string) => void | Promise; - 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 ( <> - - - - - {activeDocRow != null && ( - - )} - - {activeMediaRow != null && ( - )} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx new file mode 100644 index 00000000..9fd5723c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -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; +}; + +export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) { + const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } = + page; + + return ( + <> + page.handleOpenSlideOver(row, false)} + /> + + + + page.patchField(id, patch, options)} + onOpenCompletionDoc={page.setDocRow} + onCancelWO={page.setConfirmCancel} + onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} + saving={page.isPatchPending} + onClose={page.handleCloseSlideOver} + /> + + page.setConfirmCancel(null)} + onConfirm={page.handleConfirmCancel} + /> + + {page.activeMediaRow != null && ( + { + if (!open) page.setMediaRow(null); + }} + /> + )} + + {activeDocRow != null && ( + { + 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 }); + }} + /> + )} + + page.setUpliftsRow(null)} + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx new file mode 100644 index 00000000..f5cee496 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx @@ -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 ( + + {title} + + + {description} + + setReason(event.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx new file mode 100644 index 00000000..8e0902e6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -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; +}; + +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 ( +
+ + Request uplift + +
+ setAmount(event.target.value)} + helperText={amountHelperText(parsedAmount, remainingAllowance)} + slotProps={{ htmlInput: { min: 0, step: "0.01" } }} + /> + setNotes(event.target.value)} + /> + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx new file mode 100644 index 00000000..0578302c --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx @@ -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 ( +
+
+ + {formatUpliftStatusLabel(uplift.status)} + + + {formatUpliftMoney(uplift.amount)} + +
+ {Boolean(uplift.notes) && ( + + {uplift.notes} + + )} + {Boolean(uplift.requestedByName) && ( + + Requested by {uplift.requestedByName} + + )} + {showCancel && ( + + )} + {showRevoke && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx new file mode 100644 index 00000000..b0c0c6a0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx @@ -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; + 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 && ( + + This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only. + + )} + + {Boolean(error) && ( + + )} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !error && uplifts.length === 0 && ( + + No uplift requests yet. + + )} + + {!isLoading && + uplifts.map((uplift) => ( + onCancelUplift(uplift.id)} + onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)} + /> + ))} + + {!readOnly && !isLoading && hasOpenRequest && ( + + Cancel the pending request to file a new one. + + )} + + {!readOnly && !isLoading && !hasOpenRequest && ( + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx new file mode 100644 index 00000000..7e6cbc27 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -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(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 ( + <> + + Uplifts — WO {row?.woNumber ?? ""} + + void refetch()} + onCreate={async (input) => { + await createUplift.mutateAsync(input); + }} + onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)} + onRevokeUplift={handleRevokeUplift} + /> + + + + + + + setRevokeTarget(null)} + onConfirm={(reason) => { + if (revokeTarget == null) { + return; + } + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }} + /> + + ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index 7522bb47..914bf835 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -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; +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["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, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index b2963c11..cfcbe7b6 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -19,6 +19,7 @@ export function useWorkOrdersListPage() { const [confirmCancel, setConfirmCancel] = useState(null); const [docRow, setDocRow] = useState(null); const [mediaRow, setMediaRow] = useState(null); + const [upliftsRow, setUpliftsRow] = useState(null); const [completionDocs, setCompletionDocs] = useState>({}); 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, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 09f819b6..752272de 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -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; +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, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts index 633db819..9401ba1f 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts @@ -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, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts index 82cd07c6..196bfe69 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts @@ -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(null); const [draft, setDraft] = useState(EMPTY_TABLE_DRAFT); + const [unassignedOpen, setUnassignedOpen] = useState(true); const [unschedOpen, setUnschedOpen] = useState(true); const [selectedId, setSelectedId] = useState(null); const [flashId, setFlashId] = useState(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, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 9036de10..30bab7af 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -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 ( @@ -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} /> - page.handleOpenSlideOver(row, false)} - /> - - - - page.patchField(id, patch, options)} - onOpenCompletionDoc={page.setDocRow} - onCancelWO={page.setConfirmCancel} - onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} - saving={page.isPatchPending} - onClose={page.handleCloseSlideOver} - /> - - page.setConfirmCancel(null)} - onConfirm={page.handleConfirmCancel} - /> - - {page.activeMediaRow != null && ( - { - if (!open) page.setMediaRow(null); - }} - /> - )} - - {activeDocRow != null && ( - { - 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 }); - }} - /> - )} + ); } diff --git a/src/domain/work-orders/api/fetch-unassigned-board-rows.ts b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts new file mode 100644 index 00000000..4887496a --- /dev/null +++ b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts @@ -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 { + 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; +} diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts new file mode 100644 index 00000000..866ff164 --- /dev/null +++ b/src/domain/work-orders/api/work-order-uplifts-api.ts @@ -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) : {}; + const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts; + return Array.isArray(items) ? items : []; +} + +export const workOrderUpliftsApi = { + list: async (workOrderId: string | number): Promise => { + const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId)); + const unwrapped = handleApiResponse(data); + return mapWorkOrderUplifts(extractUpliftItems(unwrapped)); + }, + + create: async ( + workOrderId: string | number, + input: CreateWorkOrderUpliftInput, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), { + amount: input.amount, + notes: input.notes, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + cancel: async ( + workOrderId: string | number, + upliftId: string | number, + ): Promise => { + const data = await apiPost( + API_PATHS.workOrder.upliftCancel(workOrderId, upliftId), + {}, + ); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + revoke: async ( + workOrderId: string | number, + upliftId: string | number, + reason?: string, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { + reason, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, +}; diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 2ae3817c..f845f009 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -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 | null { return null; } +function readUpliftSummary(item: Record): 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, + 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; diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts new file mode 100644 index 00000000..ec712467 --- /dev/null +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -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 { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...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, ...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, ...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 | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") { + return value; + } + return ""; +} + +function readOptionalId( + record: Record, + ...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(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); +} diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index a01eed47..ac8dc872 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -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; types: Set; + severities: Set; dispatchers: Set; statuses: Set; + rescheduled: boolean; + carriedOver: boolean; + addOn: boolean; pmTypes: Set; vendorTechs: Set; docs: Set; + flagColors: Set; + internalOnly: boolean; + hasUplift: boolean; + upliftStatuses: Set; } 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 = 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"; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 40c6b3d7..9d6e822e 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -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 = { [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; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 9dd1dff3..d4dc3782 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -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; export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const; +export const UNASSIGNED_DAY_KEY = "__unassigned__" as const; diff --git a/src/domain/work-orders/types/work-order-uplift-filter.ts b/src/domain/work-orders/types/work-order-uplift-filter.ts new file mode 100644 index 00000000..a26ca0d1 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift-filter.ts @@ -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 = { + pending: "Pending", + approved: "Approved", + auto_approved: "Auto-approved", + rejected: "Rejected", +}; diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts new file mode 100644 index 00000000..aaed9c16 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift.ts @@ -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, +}; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index f266bc6a..265344da 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -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"; diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts new file mode 100644 index 00000000..ec9cd3cc --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -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, + 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 { + 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 { + 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 { + 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 { + 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"); + }, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts index 46fd94ef..59998f5a 100644 --- a/src/domain/work-orders/use-cases/use-work-orders-list.ts +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.workOrders.unassignedQueue(), + queryFn: fetchUnassignedBoardRows, + enabled, + placeholderData: keepPreviousData, + }); +} + export function useWorkOrderUsers(): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.dispatchers(), diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts new file mode 100644 index 00000000..8150c9d2 --- /dev/null +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -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; + 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, + 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(), + }); + 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, + 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, + 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(), + }); + if (filters.upliftStatuses.size) { + pushSetChip({ + chips, + key: "uplift", + group: "Uplift", + set: filters.upliftStatuses as Set, + nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v, + clear: clearUplift, + }); + return; + } + chips.push({ + key: "uplift", + group: "Uplift", + value: "Has uplift", + clear: clearUplift, + }); +} diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 13c5aa1b..112204c8 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -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, ]; diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index ad132df4..8266a7b0 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -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, - 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, - (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, - (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, - (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; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..8c1e403e 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -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): number[] | undefin return list.length > 0 ? list : undefined; } +function resolveVendorIds( + vendorTechs: Iterable, + vendorIdsByCompanyName?: Map, +): 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; }): 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, + }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 6938c74c..0bf9879c 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -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 { + 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; + queueIds: ReadonlySet; +}; + +/** + * 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[]; diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts new file mode 100644 index 00000000..26dd14c2 --- /dev/null +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -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 = { + 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, + 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>, +): boolean { + return uplifts.some((uplift) => uplift.status === "pending"); +} + +export function getRemainingUpliftAllowance( + workOrderType: string, + uplifts: ReadonlyArray>, +): number { + const consumed = uplifts + .filter((uplift) => uplift.status === "auto_approved") + .reduce((sum, uplift) => sum + uplift.amount, 0); + return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed); +} diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts new file mode 100644 index 00000000..be377091 --- /dev/null +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -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, +): boolean { + if (row.hasPendingUplift === true) { + return true; + } + return (row.upliftSummary?.pendingCount ?? 0) > 0; +} + +function hasDispatcher(row: Pick): 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, + }; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c775e00e..3b135667 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -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, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 98716ee7..55ca7411 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -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"); +} diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx index 9b981572..72fa8e30 100644 --- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx +++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx @@ -73,6 +73,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; return { ...defaults, diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index 4461a17b..18bf4862 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow { dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; } diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx new file mode 100644 index 00000000..d886232a --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -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 { + 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx index 77dccc3f..a7992bac 100644 --- a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx +++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx @@ -60,6 +60,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; return { ...defaults, diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 2d4d6d7b..9b933157 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -50,6 +50,7 @@ function baseInfo(overrides: Partial = {}): WorkOrderTableRow dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, ...overrides, }; } diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx new file mode 100644 index 00000000..66a61208 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -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 { + 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 { + 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; +}) { + 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 ( + + ( + + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber} + { + 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, + ), + ); + }} + /> +
+ ); +} + +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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + 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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + 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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + 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( + + null} + renderInlineRow={() => null} + /> +
, + ); + + 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( + + null} + renderInlineRow={() => null} + /> +
, + ); + + 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( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + expect(screen.getByText("queue unavailable")).toBeInTheDocument(); + }); + + it("keeps the Unassigned section visible with an empty state when the queue is empty", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
{row.woNumber}
, + ); + + 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(); + + 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(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index dfd730ad..36b6c520 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -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, diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index bb6aeff9..f49cf4a1 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -93,6 +93,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, ...overrides, }; } diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx new file mode 100644 index 00000000..4230eb74 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -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( + , + { 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(, { + 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(, { + 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(, { + 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( + , + { 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( + , + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument(); + }); + + it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx new file mode 100644 index 00000000..8f9fc28d --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx @@ -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( + undefined} + onClearAll={() => undefined} + />, + ); + + expect(screen.getByText("150 work orders")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index a67b9833..cffbaedf 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -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(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + 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(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + 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(, { + 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(); + + 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(); + }); }); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index b67bf1f1..08019764 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -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", () => ({ diff --git a/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts new file mode 100644 index 00000000..bf8031e9 --- /dev/null +++ b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts @@ -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[], 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); + }); +}); diff --git a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts new file mode 100644 index 00000000..599ae34e --- /dev/null +++ b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts @@ -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); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index 5251e7fe..107d63f0 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -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(); + }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts new file mode 100644 index 00000000..d765c4d5 --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts @@ -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(); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 6260d38e..80f4092b 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -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; diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index f96d19ee..c4f351fa 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -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); + }); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..5d80c1b4 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -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"); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 9883c3c1..fa9db9e1 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -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 { @@ -52,6 +53,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "1", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts index 538e7fa1..bb1c5d8a 100644 --- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts @@ -74,6 +74,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow { dispatchRowVersion: "", flagColor: null, isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index ea303e6c..678774b5 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -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 rowVersion: "AQAAAAAAAAA=", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, + ...overrides, + }; +} + +function bar( + weekMonday: string, + overrides: Partial = {}, +): 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" }); diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts new file mode 100644 index 00000000..af91feeb --- /dev/null +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -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); + }); +}); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts new file mode 100644 index 00000000..8af2724e --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts new file mode 100644 index 00000000..eddb88c5 --- /dev/null +++ b/src/test/lib/auth/user-utils.test.ts @@ -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); + }); +}); diff --git a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md new file mode 100644 index 00000000..13a8b08c --- /dev/null +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -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.