mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 12:03:11 +00:00
Merge branch 'dev' into feat/sh-250-fe-additive-add
This commit is contained in:
commit
a71407b426
83 changed files with 4566 additions and 557 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { AdvFilterPill } from "./adv-filter-pill";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { DateField } from "@/components/ui/date-input";
|
||||
import {
|
||||
DATE_RANGE_OPTIONS,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
|
||||
type AdvDateRangeSectionProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
};
|
||||
|
||||
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
|
||||
return (
|
||||
<AdvSection
|
||||
icon={CalendarDays}
|
||||
label="Date range"
|
||||
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DATE_RANGE_OPTIONS.map((option) => (
|
||||
<AdvFilterPill
|
||||
key={option.key}
|
||||
label={option.label}
|
||||
selected={draft.dateRange === option.key}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{draft.dateRange === "custom" && (
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
<DateField
|
||||
label="From"
|
||||
value={draft.customFrom}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
|
||||
/>
|
||||
<DateField
|
||||
label="To"
|
||||
value={draft.customTo}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Flame,
|
||||
|
|
@ -10,24 +10,24 @@ import {
|
|||
UserX,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||
import { AdvMultiSelect } from "./adv-multi-select";
|
||||
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { AdvSeverityList } from "./adv-severity-list";
|
||||
import { AdvTypeGrid } from "./adv-type-grid";
|
||||
import { DateField } from "@/components/ui/date-input";
|
||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
COMPLETION_DOC_OPTIONS,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
type AdvancedFilterSetKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
|
||||
type AdvancedSearchFilterSectionsProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
|
|
@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({
|
|||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<AdvSection
|
||||
icon={CalendarDays}
|
||||
label="Date range"
|
||||
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DATE_RANGE_OPTIONS.map((option) => (
|
||||
<AdvFilterPill
|
||||
key={option.key}
|
||||
label={option.label}
|
||||
selected={draft.dateRange === option.key}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{draft.dateRange === "custom" && (
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
<DateField
|
||||
label="From"
|
||||
value={draft.customFrom}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
|
||||
/>
|
||||
<DateField
|
||||
label="To"
|
||||
value={draft.customTo}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
|
||||
<AdvMultiSelect
|
||||
|
|
@ -113,6 +84,10 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={AlertTriangle} label="Severity" count={draft.severities.size}>
|
||||
<AdvSeverityList draft={draft} setDraft={setDraft} />
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
|
||||
<AdvMultiSelect
|
||||
options={[
|
||||
|
|
@ -149,6 +124,13 @@ export function AdvancedSearchFilterSections({
|
|||
</div>
|
||||
</AdvSection>
|
||||
|
||||
<AdvParityFilterSections
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
toggleIn={toggleIn}
|
||||
setMany={setMany}
|
||||
/>
|
||||
|
||||
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
||||
<AdvMultiSelect
|
||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react";
|
||||
import { Checkbox } from "@mui/material";
|
||||
import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill";
|
||||
import { AdvMultiSelect } from "./adv-multi-select";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||
import {
|
||||
type AdvancedFilterSetKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
UPLIFT_FILTER_LABELS,
|
||||
UPLIFT_FILTER_STATUSES,
|
||||
type UpliftFilterStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||
|
||||
type AdvParityFilterSectionsProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
|
||||
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
|
||||
};
|
||||
|
||||
function indicatorCount(draft: WorkOrderAdvancedFilters): number {
|
||||
return (draft.rescheduled ? 1 : 0) + (draft.carriedOver ? 1 : 0) + (draft.addOn ? 1 : 0);
|
||||
}
|
||||
|
||||
function FlagColorDot({ value }: { value: string }) {
|
||||
return (
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full"
|
||||
style={{ background: value, border: "1px solid var(--color-border)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvParityFilterSections({
|
||||
draft,
|
||||
setDraft,
|
||||
toggleIn,
|
||||
setMany,
|
||||
}: AdvParityFilterSectionsProps) {
|
||||
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.upliftStatuses);
|
||||
if (next.has(status)) {
|
||||
next.delete(status);
|
||||
} else {
|
||||
next.add(status);
|
||||
}
|
||||
return { ...prev, upliftStatuses: next };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<AdvFilterPill
|
||||
label="Rescheduled ≥ 2"
|
||||
selected={draft.rescheduled}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
<AdvFilterPill
|
||||
label="Carried Over ≥ 2"
|
||||
selected={draft.carriedOver}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
<AdvFilterPill
|
||||
label="Add-On"
|
||||
selected={draft.addOn}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
|
||||
<AdvMultiSelect
|
||||
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}
|
||||
selected={draft.flagColors}
|
||||
onToggle={(value) => toggleIn("flagColors", value)}
|
||||
onBulkToggle={(values, select) => setMany("flagColors", values, select)}
|
||||
placeholder="Search flag colour…"
|
||||
renderLeft={(value) => <FlagColorDot value={value} />}
|
||||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Hash} label="Work Order Number" count={draft.internalOnly ? 1 : 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, internalOnly: !prev.internalOnly }))}
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
>
|
||||
<span
|
||||
className="flex size-[18px] shrink-0 items-center justify-center rounded border"
|
||||
style={{
|
||||
borderColor: draft.internalOnly ? "var(--color-primary)" : "var(--color-border)",
|
||||
background: draft.internalOnly ? "var(--color-primary)" : "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{Boolean(draft.internalOnly) && <Check size={12} className="text-white" />}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Internal numbers only (SH prefix)</span>
|
||||
</button>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none">
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={draft.hasUplift}
|
||||
onChange={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
hasUplift: !prev.hasUplift,
|
||||
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
padding: 0,
|
||||
color: "var(--color-border-hover)",
|
||||
"&.Mui-checked": { color: "var(--color-primary)" },
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Has uplift</span>
|
||||
</label>
|
||||
{Boolean(draft.hasUplift) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{UPLIFT_FILTER_STATUSES.map((status) => (
|
||||
<AdvDocPill
|
||||
key={status}
|
||||
label={UPLIFT_FILTER_LABELS[status]}
|
||||
selected={draft.upliftStatuses.has(status)}
|
||||
onClick={() => toggleUpliftStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_TOKEN_COLOR,
|
||||
WORK_ORDER_SEVERITIES,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
|
||||
type AdvSeverityListProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
};
|
||||
|
||||
export function AdvSeverityList({ draft, setDraft }: AdvSeverityListProps) {
|
||||
const toggleLevel = (level: WorkOrderSeverity) => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.severities);
|
||||
if (next.has(level)) {
|
||||
next.delete(level);
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return { ...prev, severities: next };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex justify-end">
|
||||
<SelectAllAction
|
||||
anySelected={draft.severities.size > 0}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
severities:
|
||||
prev.severities.size > 0
|
||||
? new Set()
|
||||
: new Set<WorkOrderSeverity>(WORK_ORDER_SEVERITIES),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{WORK_ORDER_SEVERITIES.map((level) => {
|
||||
const meta = SEVERITY_LEVELS[level];
|
||||
const color = SEVERITY_TOKEN_COLOR[meta.token];
|
||||
const selected = draft.severities.has(level);
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => toggleLevel(level)}
|
||||
className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-colors"
|
||||
style={{
|
||||
borderColor: selected ? color : "var(--color-border)",
|
||||
background: selected ? "var(--color-bg-muted)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded"
|
||||
style={{
|
||||
border: `1px solid ${selected ? color : "var(--color-border)"}`,
|
||||
background: selected ? color : "transparent",
|
||||
}}
|
||||
>
|
||||
{Boolean(selected) && <Check size={11} className="text-white" />}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded border px-1.5 py-px text-[11px] font-semibold whitespace-nowrap"
|
||||
style={{ color, borderColor: color }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="flex-1 text-xs text-foreground">{meta.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ type DispatcherFilterProps = {
|
|||
users: WorkOrderUser[];
|
||||
usersLoading?: boolean;
|
||||
currentUserId?: string | number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function userInitials(name: string): string {
|
||||
|
|
@ -40,6 +41,7 @@ export function DispatcherFilter({
|
|||
users,
|
||||
usersLoading = false,
|
||||
currentUserId,
|
||||
disabled = false,
|
||||
}: DispatcherFilterProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
||||
|
|
@ -86,6 +88,7 @@ export function DispatcherFilter({
|
|||
<MenuAnchorButton
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
||||
style={
|
||||
!allSelected
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ type WeekNavProps = {
|
|||
weekMonday: string;
|
||||
|
||||
onChange: (mondayIso: string) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const VISIBLE_COUNT = 3;
|
||||
|
|
@ -51,7 +53,7 @@ function WeekLiveBadge() {
|
|||
);
|
||||
}
|
||||
|
||||
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||
export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
|
||||
const weeks = useMemo(
|
||||
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<NavArrowButton
|
||||
disabled={!canGoBack}
|
||||
disabled={disabled || !canGoBack}
|
||||
onClick={() => setOffset((current) => current - 1)}
|
||||
aria-label="Previous weeks"
|
||||
>
|
||||
|
|
@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
<NavPillButton
|
||||
key={week.mondayIso}
|
||||
active={isActive}
|
||||
disabled={isDisabled}
|
||||
disabled={disabled || isDisabled}
|
||||
onClick={() => onChange(week.mondayIso)}
|
||||
>
|
||||
{week.label}
|
||||
|
|
@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
})}
|
||||
|
||||
<NavArrowButton
|
||||
disabled={!canGoForward}
|
||||
disabled={disabled || !canGoForward}
|
||||
onClick={() => setOffset((current) => current + 1)}
|
||||
aria-label="Next weeks"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@ type WoTypeSegmentProps = {
|
|||
value: WorkOrderTypeFilter;
|
||||
|
||||
onChange: (value: WorkOrderTypeFilter) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
|
||||
|
||||
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
|
||||
export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) {
|
||||
return (
|
||||
<SegmentGroup>
|
||||
{OPTIONS.map((option) => (
|
||||
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
|
||||
<SegmentButton
|
||||
key={option}
|
||||
active={value === option}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option)}
|
||||
>
|
||||
{option}
|
||||
</SegmentButton>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type WorkOrdersFilterBarProps = {
|
|||
currentUserId?: string | number;
|
||||
filteredCount: number;
|
||||
scopeTotalCount: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrdersFilterBar({
|
||||
|
|
@ -30,20 +31,25 @@ export function WorkOrdersFilterBar({
|
|||
currentUserId,
|
||||
filteredCount,
|
||||
scopeTotalCount,
|
||||
disabled = false,
|
||||
}: WorkOrdersFilterBarProps) {
|
||||
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
|
||||
<div
|
||||
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||
<div className="flex-1" />
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
/>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave("", "");
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Unassigned</span>
|
||||
{!dispatcherId && <Check size={10} className="shrink-0 text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
{filtered.map((d) => (
|
||||
<button
|
||||
key={String(d.id)}
|
||||
|
|
|
|||
|
|
@ -1,108 +1,126 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { AlertTriangle, Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
type StatusCellProps = {
|
||||
wo: WorkOrderTableRow;
|
||||
|
||||
onChangeStatus: (status: WizardWOStatus) => void;
|
||||
|
||||
onOpenSlideOver: () => void;
|
||||
|
||||
onPastDueAttempt?: () => void;
|
||||
};
|
||||
|
||||
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||
return getClosabilityGaps({
|
||||
dispatcherId: wo.dispatcherId,
|
||||
dispatcherName: wo.dispatcherName,
|
||||
company: wo.company,
|
||||
tech: wo.tech,
|
||||
completedDate: wo.completedDate ?? "",
|
||||
woNumber: wo.woNumber,
|
||||
pm: wo.pm,
|
||||
docStatus: wo.docStatus,
|
||||
mediaCount: wo.mediaCount,
|
||||
type: wo.type,
|
||||
upliftSummary: wo.upliftSummary,
|
||||
hasPendingUplift: wo.hasPendingUplift,
|
||||
});
|
||||
}
|
||||
|
||||
function PastDueStatusCell({
|
||||
onOpenSlideOver,
|
||||
onPastDueAttempt,
|
||||
}: {
|
||||
onOpenSlideOver: () => void;
|
||||
onPastDueAttempt?: () => void;
|
||||
}) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||
Past Due
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{
|
||||
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 p-3"
|
||||
style={{ background: "var(--status-pending-bg)" }}
|
||||
>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.375,
|
||||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSlideOver();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusCell({
|
||||
wo,
|
||||
|
||||
onChangeStatus,
|
||||
|
||||
onOpenSlideOver,
|
||||
|
||||
onPastDueAttempt,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
||||
if (wo.isPastDue) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onPastDueAttempt?.();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||
Past Due
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{
|
||||
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 p-3"
|
||||
style={{ background: "var(--status-pending-bg)" }}
|
||||
>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.375,
|
||||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
</Text>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onOpenSlideOver();
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +130,6 @@ export function StatusCell({
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
|
|
@ -122,7 +139,6 @@ export function StatusCell({
|
|||
) : (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
|
|
@ -134,24 +150,38 @@ export function StatusCell({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChangeStatus(status);
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
||||
{wo.status === status && (
|
||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
return;
|
||||
}
|
||||
onChangeStatus(status);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
{Boolean(wo.status === status) && (
|
||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
getUpliftCellLabel,
|
||||
getUpliftStatusPillStyle,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type UpliftCellProps = {
|
||||
summary: UpliftSummary;
|
||||
onOpen?: () => void;
|
||||
};
|
||||
|
||||
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
|
||||
const label = getUpliftCellLabel(summary);
|
||||
|
||||
if (!onOpen) {
|
||||
return label ? (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||
);
|
||||
}
|
||||
|
||||
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
|
||||
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
title={label ? "View uplifts" : "Manage uplifts"}
|
||||
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label && pillStyle ? (
|
||||
<span
|
||||
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
|
||||
style={pillStyle}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { Fragment, type ReactNode } from "react";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
type WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||
|
||||
type WoTableBodyProps = {
|
||||
|
|
@ -15,11 +20,65 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
|||
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||
}
|
||||
|
||||
const unassignedRows = tableData.unassigned ?? [];
|
||||
const showUnscheduled =
|
||||
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
(tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
// SH-165: "still loading" and "zero unassigned" are distinct states. Without
|
||||
// this split the pin announced "No unassigned work orders." while the queue
|
||||
// was still paging board/search on first mount.
|
||||
const showUnassignedLoading =
|
||||
tableData.unassignedOpen &&
|
||||
unassignedRows.length === 0 &&
|
||||
!tableData.unassignedError &&
|
||||
tableData.unassignedLoading;
|
||||
const showUnassignedEmpty =
|
||||
tableData.unassignedOpen &&
|
||||
unassignedRows.length === 0 &&
|
||||
!tableData.unassignedError &&
|
||||
!tableData.unassignedLoading;
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
<WoTableSectionHeader
|
||||
variant="unassigned"
|
||||
label="Unassigned"
|
||||
count={unassignedRows.length}
|
||||
collapsed={!tableData.unassignedOpen}
|
||||
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
|
||||
/>
|
||||
{tableData.unassignedOpen && (
|
||||
<>
|
||||
{renderInlineRow(UNASSIGNED_DAY_KEY)}
|
||||
{Boolean(tableData.unassignedError) && (
|
||||
<tr>
|
||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||
<Text variant="error" tone="error">
|
||||
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showUnassignedLoading && (
|
||||
<tr aria-busy="true">
|
||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||
<Text variant="feedback" tone="muted">
|
||||
Loading unassigned work orders…
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showUnassignedEmpty && (
|
||||
<tr>
|
||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||
<Text variant="feedback" tone="muted">
|
||||
No unassigned work orders.
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{unassignedRows.map((row) => renderRow(row))}
|
||||
</>
|
||||
)}
|
||||
{showUnscheduled && (
|
||||
<>
|
||||
<WoTableSectionHeader
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||
|
||||
type CollapsibleSectionStyle = {
|
||||
background: string;
|
||||
borderTop: string;
|
||||
borderBottom: string;
|
||||
accentColor: string;
|
||||
boxShadow: string;
|
||||
labelFontFamily: string;
|
||||
labelLetterSpacing: string;
|
||||
};
|
||||
|
||||
const SECTION_STYLES: Record<"unassigned" | "unscheduled", CollapsibleSectionStyle> = {
|
||||
unassigned: {
|
||||
background: "rgba(99, 102, 241, 0.08)",
|
||||
borderTop: "1px solid #C7D2FE",
|
||||
borderBottom: "1px solid #C7D2FE",
|
||||
accentColor: "var(--primary)",
|
||||
boxShadow: "inset 3px 0 0 var(--primary)",
|
||||
labelFontFamily: "var(--font-mono)",
|
||||
labelLetterSpacing: "0.08em",
|
||||
},
|
||||
unscheduled: {
|
||||
background: "rgba(217, 119, 6, 0.08)",
|
||||
borderTop: "1px solid #FDE68A",
|
||||
borderBottom: "1px solid #FDE68A",
|
||||
accentColor: "var(--warning)",
|
||||
boxShadow: "inset 3px 0 0 var(--warning)",
|
||||
labelFontFamily: "var(--font-mono)",
|
||||
labelLetterSpacing: "0.08em",
|
||||
},
|
||||
};
|
||||
|
||||
type WoTableCollapsibleSectionHeaderProps = {
|
||||
tone: "unassigned" | "unscheduled";
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
count: number;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onAddWo?: () => void;
|
||||
};
|
||||
|
||||
export function WoTableCollapsibleSectionHeader({
|
||||
tone,
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onAddWo,
|
||||
}: WoTableCollapsibleSectionHeaderProps) {
|
||||
const style = SECTION_STYLES[tone];
|
||||
|
||||
return (
|
||||
<tr style={{ background: style.background }}>
|
||||
<td
|
||||
colSpan={WO_TABLE_COLS.length}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 14px",
|
||||
boxShadow: style.boxShadow,
|
||||
borderTop: style.borderTop,
|
||||
borderBottom: style.borderBottom,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2.5"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={`${label}, ${count} work order${count !== 1 ? "s" : ""}${collapsed ? ", collapsed" : ", expanded"}`}
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" style={{ color: style.accentColor }} />
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: style.labelFontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: style.labelLetterSpacing,
|
||||
color: style.accentColor,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{count} work order{count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center" style={{ color: style.accentColor }}>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{Boolean(onAddWo) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||
onClick={onAddWo}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add WO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
|
|||
import { DateCell } from "./cells/date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { UpliftCell } from "./cells/uplift-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
||||
type WoTableRowServiceCellsProps = {
|
||||
|
|
@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
|
|||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<UpliftCell
|
||||
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
|
||||
onOpen={
|
||||
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
|
||||
handlers.onOpenUplifts
|
||||
? () => handlers.onOpenUplifts!(row)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
|
|||
onView: (row: WorkOrderTableRow) => void;
|
||||
onEdit: (row: WorkOrderTableRow) => void;
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||
import { AlertTriangle, Plus, Users } from "lucide-react";
|
||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||
import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header";
|
||||
|
||||
type WoTableSectionHeaderProps = {
|
||||
variant: "unscheduled" | "day";
|
||||
variant: "unassigned" | "unscheduled" | "day";
|
||||
label: string;
|
||||
count: number;
|
||||
isToday?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onAddWo: () => void;
|
||||
onAddWo?: () => void;
|
||||
};
|
||||
|
||||
export function WoTableSectionHeader({
|
||||
|
|
@ -20,58 +21,30 @@ export function WoTableSectionHeader({
|
|||
onToggleCollapse,
|
||||
onAddWo,
|
||||
}: WoTableSectionHeaderProps) {
|
||||
const isUnscheduled = variant === "unscheduled";
|
||||
|
||||
if (isUnscheduled) {
|
||||
if (variant === "unassigned") {
|
||||
return (
|
||||
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
|
||||
<td
|
||||
colSpan={WO_TABLE_COLS.length}
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 14px",
|
||||
cursor: "pointer",
|
||||
boxShadow: "inset 3px 0 0 var(--warning)",
|
||||
borderTop: "1px solid #FDE68A",
|
||||
borderBottom: "1px solid #FDE68A",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--warning)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{count} work order{count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddWo();
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add WO
|
||||
</button>
|
||||
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<WoTableCollapsibleSectionHeader
|
||||
tone="unassigned"
|
||||
icon={Users}
|
||||
label={label}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => onToggleCollapse?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "unscheduled") {
|
||||
return (
|
||||
<WoTableCollapsibleSectionHeader
|
||||
tone="unscheduled"
|
||||
icon={AlertTriangle}
|
||||
label={label}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => onToggleCollapse?.()}
|
||||
onAddWo={onAddWo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -125,18 +98,17 @@ export function WoTableSectionHeader({
|
|||
Today
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddWo();
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add WO
|
||||
</button>
|
||||
{Boolean(onAddWo) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||
onClick={onAddWo}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add WO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,88 +1,22 @@
|
|||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
|
||||
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListOverlaysProps = {
|
||||
wizardOpen: boolean;
|
||||
onWizardOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
pmTypes: string[];
|
||||
vendorTechs: string[];
|
||||
advOpen: boolean;
|
||||
onAdvOpenChange: (open: boolean) => void;
|
||||
advInitial: WorkOrderAdvancedFilters;
|
||||
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
|
||||
onClearAdvanced: () => void;
|
||||
activeDocRow: WorkOrderTableRow | null;
|
||||
onDocOpenChange: (open: boolean) => void;
|
||||
completionDocs: Record<string, CompDocData>;
|
||||
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange: (date: string) => void | Promise<void>;
|
||||
activeMediaRow: WorkOrderTableRow | null;
|
||||
onMediaOpenChange: (open: boolean) => void;
|
||||
activeUpliftsRow: WorkOrderTableRow | null;
|
||||
onCloseUplifts: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListOverlays({
|
||||
wizardOpen,
|
||||
onWizardOpenChange,
|
||||
users,
|
||||
sites,
|
||||
pmTypes,
|
||||
vendorTechs,
|
||||
advOpen,
|
||||
onAdvOpenChange,
|
||||
advInitial,
|
||||
onApplyAdvanced,
|
||||
onClearAdvanced,
|
||||
activeDocRow,
|
||||
onDocOpenChange,
|
||||
completionDocs,
|
||||
onSaveCompDoc,
|
||||
onDateCompletedChange,
|
||||
activeMediaRow,
|
||||
onMediaOpenChange,
|
||||
activeUpliftsRow,
|
||||
onCloseUplifts,
|
||||
}: WorkOrdersListOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={advOpen}
|
||||
onOpenChange={onAdvOpenChange}
|
||||
initial={advInitial}
|
||||
onApply={onApplyAdvanced}
|
||||
onClearAll={onClearAdvanced}
|
||||
sites={sites}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={onDocOpenChange}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
|
||||
// Notes/generatedAt stay client-local (no content endpoint on this branch).
|
||||
onSave={onSaveCompDoc}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={onMediaOpenChange}
|
||||
{activeUpliftsRow != null && (
|
||||
<WorkOrderUpliftsDialog
|
||||
row={activeUpliftsRow}
|
||||
open={Boolean(activeUpliftsRow)}
|
||||
onClose={onCloseUplifts}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
||||
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
|
||||
type WorkOrdersListPagePanelsProps = {
|
||||
page: ReturnType<typeof useWorkOrdersListPage>;
|
||||
};
|
||||
|
||||
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
|
||||
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
|
||||
page;
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkOrdersListWizard
|
||||
open={page.wizardOpen}
|
||||
onOpenChange={page.setWizardOpen}
|
||||
users={users}
|
||||
rows={tableData.rows}
|
||||
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
|
||||
/>
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={locations}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<WorkOrderSlideOver
|
||||
row={page.activeSlideOverRow}
|
||||
editMode={page.slideOverEdit}
|
||||
users={users}
|
||||
sites={locations}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={page.setDocRow}
|
||||
onCancelWO={page.setConfirmCancel}
|
||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||
saving={page.isPatchPending}
|
||||
onClose={page.handleCloseSlideOver}
|
||||
/>
|
||||
|
||||
<WorkOrderCancelDialog
|
||||
row={page.confirmCancel}
|
||||
isPending={page.isPatchPending}
|
||||
onDialogClose={page.cancelDialogClose}
|
||||
onKeepWO={() => page.setConfirmCancel(null)}
|
||||
onConfirm={page.handleConfirmCancel}
|
||||
/>
|
||||
|
||||
{page.activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={page.activeMediaRow}
|
||||
open={Boolean(page.activeMediaRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={Boolean(activeDocRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
data={page.completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||
onDateCompletedChange={async (date) => {
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkOrdersListOverlays
|
||||
activeUpliftsRow={page.activeUpliftsRow}
|
||||
onCloseUplifts={() => page.setUpliftsRow(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type RevokeUpliftDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
pending?: boolean;
|
||||
requireReason?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function RevokeUpliftDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
requireReason = false,
|
||||
title = "Revoke auto-approved uplift?",
|
||||
description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
|
||||
}: RevokeUpliftDialogProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
// Clear only after the dialog closes (parent closes on mutation success).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setReason("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(reason.trim());
|
||||
};
|
||||
|
||||
const reasonMissing = requireReason && reason.trim() === "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text as="p" variant="description" className="mb-3">
|
||||
{description}
|
||||
</Text>
|
||||
<TextField
|
||||
label={requireReason ? "Reason (required)" : "Reason (optional)"}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
required={requireReason}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={pending || reasonMissing}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { useState } from "react";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type WorkOrderUpliftCreateFormProps = {
|
||||
pending?: boolean;
|
||||
remainingAllowance: number;
|
||||
onSubmit: (input: { amount: number; notes: string }) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function amountHelperText(parsedAmount: number, remainingAllowance: number): string {
|
||||
const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`;
|
||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||
return remaining;
|
||||
}
|
||||
if (parsedAmount <= remainingAllowance) {
|
||||
return `${remaining} This request will auto-approve.`;
|
||||
}
|
||||
return `${remaining} This request will go to the admin approval queue.`;
|
||||
}
|
||||
|
||||
export function WorkOrderUpliftCreateForm({
|
||||
pending = false,
|
||||
remainingAllowance,
|
||||
onSubmit,
|
||||
}: WorkOrderUpliftCreateFormProps) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const parsedAmount = Number(amount);
|
||||
const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
|
||||
setAmount("");
|
||||
setNotes("");
|
||||
} catch {
|
||||
// Mutation onError already toasts; keep form values after failure.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
<Text as="p" variant="label" className="mb-2">
|
||||
Request uplift
|
||||
</Text>
|
||||
<div className="grid gap-2">
|
||||
<TextField
|
||||
label="Amount"
|
||||
type="number"
|
||||
size="small"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
helperText={amountHelperText(parsedAmount, remainingAllowance)}
|
||||
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Attachments"
|
||||
size="small"
|
||||
disabled
|
||||
helperText="Attachment upload will be wired when BE accepts files."
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
Create uplift
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { Button } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
canRevokeWorkOrderUplift,
|
||||
formatUpliftMoney,
|
||||
formatUpliftStatusLabel,
|
||||
getUpliftStatusPillStyle,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type WorkOrderUpliftListItemProps = {
|
||||
uplift: WorkOrderUplift;
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
pendingAction?: boolean;
|
||||
onCancel?: () => void;
|
||||
onRevoke?: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftListItem({
|
||||
uplift,
|
||||
readOnly,
|
||||
currentUserId,
|
||||
pendingAction = false,
|
||||
onCancel,
|
||||
onRevoke,
|
||||
}: WorkOrderUpliftListItemProps) {
|
||||
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
||||
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
|
||||
const showRevoke =
|
||||
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
||||
style={pillStyle}
|
||||
>
|
||||
{formatUpliftStatusLabel(uplift.status)}
|
||||
</span>
|
||||
<Text as="span" variant="label">
|
||||
{formatUpliftMoney(uplift.amount)}
|
||||
</Text>
|
||||
</div>
|
||||
{Boolean(uplift.notes) && (
|
||||
<Text as="p" variant="description" className="mb-2">
|
||||
{uplift.notes}
|
||||
</Text>
|
||||
)}
|
||||
{Boolean(uplift.requestedByName) && (
|
||||
<Text as="p" variant="caption" tone="muted">
|
||||
Requested by {uplift.requestedByName}
|
||||
</Text>
|
||||
)}
|
||||
{showCancel && (
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
disabled={pendingAction}
|
||||
onClick={onCancel}
|
||||
className="mt-2"
|
||||
>
|
||||
Cancel pending
|
||||
</Button>
|
||||
)}
|
||||
{showRevoke && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={pendingAction}
|
||||
onClick={onRevoke}
|
||||
className="mt-2"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
|
||||
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type WorkOrderUpliftsDialogContentProps = {
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
readOnlyStatusLabel?: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
uplifts: WorkOrderUplift[];
|
||||
remainingAllowance: number;
|
||||
actionPending: boolean;
|
||||
createPending: boolean;
|
||||
onRetry: () => void;
|
||||
onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise<void>;
|
||||
onCancelUplift: (upliftId: string | number) => void;
|
||||
onRevokeUplift: (upliftId: string | number) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftsDialogContent({
|
||||
readOnly,
|
||||
currentUserId,
|
||||
readOnlyStatusLabel,
|
||||
isLoading,
|
||||
error,
|
||||
uplifts,
|
||||
remainingAllowance,
|
||||
actionPending,
|
||||
createPending,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onCancelUplift,
|
||||
onRevokeUplift,
|
||||
}: WorkOrderUpliftsDialogContentProps) {
|
||||
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly && (
|
||||
<Text as="p" variant="description" tone="muted">
|
||||
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{Boolean(error) && (
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message={error instanceof Error ? error.message : "Failed to load uplifts."}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-6">
|
||||
<CircularProgress size={24} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && uplifts.length === 0 && (
|
||||
<Text as="p" variant="description" tone="muted">
|
||||
No uplift requests yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
uplifts.map((uplift) => (
|
||||
<WorkOrderUpliftListItem
|
||||
key={String(uplift.id)}
|
||||
uplift={uplift}
|
||||
readOnly={readOnly}
|
||||
currentUserId={currentUserId}
|
||||
pendingAction={actionPending}
|
||||
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
|
||||
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!readOnly && !isLoading && hasOpenRequest && (
|
||||
<Text as="p" variant="description" tone="muted">
|
||||
Cancel the pending request to file a new one.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!readOnly && !isLoading && !hasOpenRequest && (
|
||||
<WorkOrderUpliftCreateForm
|
||||
pending={createPending}
|
||||
remainingAllowance={remainingAllowance}
|
||||
onSubmit={onCreate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
|
||||
import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
useCancelWorkOrderUplift,
|
||||
useCreateWorkOrderUplift,
|
||||
useRevokeWorkOrderUplift,
|
||||
useWorkOrderUplifts,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
|
||||
import {
|
||||
getRemainingUpliftAllowance,
|
||||
isWorkOrderUpliftsReadOnly,
|
||||
upliftRevokeRequiresReason,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
type RevokeTarget = {
|
||||
id: string | number;
|
||||
status: WorkOrderUpliftStatus;
|
||||
};
|
||||
|
||||
type WorkOrderUpliftsDialogProps = {
|
||||
row: WorkOrderTableRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
|
||||
const { user } = useAuthContext();
|
||||
const workOrderId = row?.id ?? null;
|
||||
const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
|
||||
const {
|
||||
data: uplifts = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useWorkOrderUplifts(open ? workOrderId : null);
|
||||
const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
|
||||
const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
|
||||
const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
|
||||
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
|
||||
|
||||
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
|
||||
|
||||
const revokeDialogCopy = useMemo(() => {
|
||||
if (revokeTarget?.status === "approved") {
|
||||
return {
|
||||
title: "Revoke approved uplift?",
|
||||
description:
|
||||
"Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Revoke auto-approved uplift?",
|
||||
description:
|
||||
"This will mark the uplift as revoked. Optional reason is stored with the decision.",
|
||||
};
|
||||
}, [revokeTarget?.status]);
|
||||
|
||||
const handleRevokeUplift = (upliftId: string | number) => {
|
||||
const uplift = uplifts.find((item) => String(item.id) === String(upliftId));
|
||||
if (!uplift) {
|
||||
return;
|
||||
}
|
||||
setRevokeTarget({ id: upliftId, status: uplift.status });
|
||||
};
|
||||
|
||||
const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
|
||||
<DialogContent className="flex flex-col gap-3">
|
||||
<WorkOrderUpliftsDialogContent
|
||||
readOnly={readOnly}
|
||||
currentUserId={user?.id}
|
||||
readOnlyStatusLabel={row?.status}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
uplifts={uplifts}
|
||||
remainingAllowance={remainingAllowance}
|
||||
actionPending={actionPending}
|
||||
createPending={createUplift.isPending}
|
||||
onRetry={() => void refetch()}
|
||||
onCreate={async (input) => {
|
||||
await createUplift.mutateAsync(input);
|
||||
}}
|
||||
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
|
||||
onRevokeUplift={handleRevokeUplift}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<RevokeUpliftDialog
|
||||
open={revokeTarget != null}
|
||||
pending={revokeUplift.isPending}
|
||||
requireReason={
|
||||
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
|
||||
}
|
||||
title={revokeDialogCopy.title}
|
||||
description={revokeDialogCopy.description}
|
||||
onClose={() => setRevokeTarget(null)}
|
||||
onConfirm={(reason) => {
|
||||
if (revokeTarget == null) {
|
||||
return;
|
||||
}
|
||||
revokeUplift.mutate(
|
||||
{ upliftId: revokeTarget.id, reason },
|
||||
{ onSuccess: () => setRevokeTarget(null) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,29 @@
|
|||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||
import {
|
||||
useWorkOrdersBoard,
|
||||
useWorkOrdersBoardSearch,
|
||||
useWorkOrdersUnassignedQueue,
|
||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
|
||||
|
||||
export type WorkOrdersBoardSource = {
|
||||
advActive: boolean;
|
||||
data: WorkOrderBoardListResult | undefined;
|
||||
unassignedQueue: WorkOrderTableRow[];
|
||||
unassignedError: Error | null;
|
||||
unassignedLoading: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
|
|
@ -67,13 +74,35 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
|||
error: searchError,
|
||||
refetch: refetchSearch,
|
||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||
const {
|
||||
data: queueData,
|
||||
isLoading: queueLoading,
|
||||
isFetching: queueFetching,
|
||||
error: queueError,
|
||||
refetch: refetchQueue,
|
||||
} = useWorkOrdersUnassignedQueue(!advActive);
|
||||
|
||||
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
|
||||
useCallback(
|
||||
async (options) => {
|
||||
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
|
||||
return boardResult;
|
||||
},
|
||||
[refetchBoard, refetchQueue],
|
||||
);
|
||||
|
||||
return {
|
||||
advActive,
|
||||
data: advActive ? searchData : boardData,
|
||||
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
|
||||
unassignedError: advActive ? null : (queueError ?? null),
|
||||
// SH-165: the pinned queue has its own first-load state. Folding it into
|
||||
// isFetching only made "still paging board/search" indistinguishable from
|
||||
// "zero unassigned", so the pin announced an empty queue while loading.
|
||||
unassignedLoading: advActive ? false : queueLoading,
|
||||
isLoading: advActive ? searchLoading : boardLoading,
|
||||
isFetching: advActive ? searchFetching : boardFetching,
|
||||
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
|
||||
error: advActive ? searchError : boardError,
|
||||
refetch: advActive ? refetchSearch : refetchBoard,
|
||||
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export function useWorkOrdersListPage() {
|
|||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||
const filters = useWorkOrdersListFilters();
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
|
|
@ -62,6 +63,11 @@ export function useWorkOrdersListPage() {
|
|||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||
}, [mediaRow, tableData.rows]);
|
||||
|
||||
const activeUpliftsRow = useMemo(() => {
|
||||
if (!upliftsRow) return null;
|
||||
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
|
||||
}, [upliftsRow, tableData.rows]);
|
||||
|
||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||
setSlideOverRow(row);
|
||||
setSlideOverEdit(edit);
|
||||
|
|
@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
|
|||
setDocRow,
|
||||
mediaRow,
|
||||
setMediaRow,
|
||||
setUpliftsRow,
|
||||
completionDocs,
|
||||
filters,
|
||||
tableData,
|
||||
|
|
@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
|
|||
activeSlideOverRow,
|
||||
activeDocRow,
|
||||
activeMediaRow,
|
||||
activeUpliftsRow,
|
||||
handleOpenSlideOver,
|
||||
handleCloseSlideOver,
|
||||
handleConfirmCancel,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo
|
|||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
buildDayGroups,
|
||||
composeBoardSections,
|
||||
filterByBarAndSearch,
|
||||
mergeWeeklyAndUnassignedQueue,
|
||||
rowIdSet,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
|
|
@ -14,12 +18,31 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
|
|||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||
|
||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||
const { advActive, data, isLoading, isFetching, error, refetch } =
|
||||
useWorkOrdersBoardSource(filters);
|
||||
const {
|
||||
advActive,
|
||||
data,
|
||||
unassignedQueue,
|
||||
unassignedError,
|
||||
unassignedLoading,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useWorkOrdersBoardSource(filters);
|
||||
|
||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||
const items = useMemo(
|
||||
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
|
||||
[weeklyItems, unassignedQueue],
|
||||
);
|
||||
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
|
||||
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
|
||||
|
||||
const local = useWorkOrdersTableLocalState({
|
||||
items: data?.items ?? [],
|
||||
items,
|
||||
weekMonday: filters.weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
|
|
@ -38,10 +61,18 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(
|
||||
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
||||
[advActive, advResults, filtered],
|
||||
);
|
||||
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
const split = splitScheduledUnscheduled(advResults);
|
||||
return { unassigned: [], ...split };
|
||||
}
|
||||
return composeBoardSections({
|
||||
displayRows,
|
||||
weekRows: filtered,
|
||||
weeklyIds,
|
||||
queueIds,
|
||||
});
|
||||
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
||||
|
||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||
|
|
@ -49,7 +80,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
const searchActive = filters.search.trim() !== "";
|
||||
const visibleRows = advActive ? advResults : filtered;
|
||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
const isEmptyWeek =
|
||||
!advActive &&
|
||||
scheduled.length === 0 &&
|
||||
unassigned.length === 0 &&
|
||||
unscheduled.length === 0 &&
|
||||
!searchActive;
|
||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||
|
||||
const todayKey =
|
||||
|
|
@ -70,6 +106,9 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
refetch,
|
||||
advActive,
|
||||
advResults,
|
||||
unassigned,
|
||||
unassignedError,
|
||||
unassignedLoading,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
|
|
@ -79,6 +118,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
draftDayKey: local.draftDayKey,
|
||||
draft: local.draft,
|
||||
setDraft: local.setDraft,
|
||||
unassignedOpen: local.unassignedOpen,
|
||||
setUnassignedOpen: local.setUnassignedOpen,
|
||||
unschedOpen: local.unschedOpen,
|
||||
setUnschedOpen: local.setUnschedOpen,
|
||||
selectedId: local.selectedId,
|
||||
|
|
@ -99,7 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
scopeTotalCount: data?.totalCount ?? 0,
|
||||
totalCount: advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: (data?.returnedCount ?? visibleRows.length),
|
||||
: (data?.returnedCount ?? weeklyItems.length),
|
||||
search: filters.search,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
type WorkOrderTableDraft,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
|
@ -11,7 +12,7 @@ export type DragVizState = {
|
|||
};
|
||||
|
||||
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
||||
if (key === UNSCHEDULED_DAY_KEY) {
|
||||
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||
return "";
|
||||
}
|
||||
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||
|
|
@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?:
|
|||
return "";
|
||||
}
|
||||
|
||||
/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */
|
||||
/** Draft, selection, pinned-section accordions, drag viz and flash scroll helpers. */
|
||||
export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||
const [unassignedOpen, setUnassignedOpen] = useState(true);
|
||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||
|
|
@ -50,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
|||
(key: string, scheduledIso?: string) => {
|
||||
setDraftDayKey(key);
|
||||
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
||||
if (key === UNSCHEDULED_DAY_KEY) {
|
||||
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||
return;
|
||||
}
|
||||
|
|
@ -85,6 +87,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
|||
setDraft,
|
||||
startDayDraft,
|
||||
cancelDraft,
|
||||
unassignedOpen,
|
||||
setUnassignedOpen,
|
||||
unschedOpen,
|
||||
setUnschedOpen,
|
||||
selectedId,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
||||
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
|||
export default function WorkOrdersListPage() {
|
||||
const { user } = useAuthContext();
|
||||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData, activeDocRow } = page;
|
||||
const { filters, tableData } = page;
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
|
|
@ -49,6 +44,7 @@ export default function WorkOrdersListPage() {
|
|||
currentUserId={user?.id}
|
||||
filteredCount={tableData.totalCount}
|
||||
scopeTotalCount={tableData.scopeTotalCount}
|
||||
disabled={filters.advApplied != null}
|
||||
/>
|
||||
|
||||
{filters.advApplied != null && (
|
||||
|
|
@ -86,79 +82,13 @@ export default function WorkOrdersListPage() {
|
|||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
onOpenCompDoc={page.setDocRow}
|
||||
onOpenMedia={page.setMediaRow}
|
||||
onOpenUplifts={page.setUpliftsRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={page.patchField}
|
||||
/>
|
||||
|
||||
<WorkOrdersListWizard
|
||||
open={page.wizardOpen}
|
||||
onOpenChange={page.setWizardOpen}
|
||||
users={page.users}
|
||||
rows={tableData.rows}
|
||||
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
|
||||
/>
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={page.locations}
|
||||
users={page.users}
|
||||
pmTypes={page.pmTypes}
|
||||
vendorTechs={page.vendorTechs}
|
||||
/>
|
||||
|
||||
<WorkOrderSlideOver
|
||||
row={page.activeSlideOverRow}
|
||||
editMode={page.slideOverEdit}
|
||||
users={page.users}
|
||||
sites={page.locations}
|
||||
vendors={page.vendors}
|
||||
pmTypes={page.pmTypes}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={page.setDocRow}
|
||||
onCancelWO={page.setConfirmCancel}
|
||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||
saving={page.isPatchPending}
|
||||
onClose={page.handleCloseSlideOver}
|
||||
/>
|
||||
|
||||
<WorkOrderCancelDialog
|
||||
row={page.confirmCancel}
|
||||
isPending={page.isPatchPending}
|
||||
onDialogClose={page.cancelDialogClose}
|
||||
onKeepWO={() => page.setConfirmCancel(null)}
|
||||
onConfirm={page.handleConfirmCancel}
|
||||
/>
|
||||
|
||||
{page.activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={page.activeMediaRow}
|
||||
open={Boolean(page.activeMediaRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={Boolean(activeDocRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
data={page.completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||
onDateCompletedChange={async (date) => {
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<WorkOrdersListPagePanels page={page} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal file
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
|
||||
|
||||
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
|
||||
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
|
||||
const rows: WorkOrderTableRow[] = [];
|
||||
let page = 0;
|
||||
let totalCount = Number.POSITIVE_INFINITY;
|
||||
|
||||
while (rows.length < totalCount) {
|
||||
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
|
||||
throw new Error(
|
||||
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
|
||||
);
|
||||
}
|
||||
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
|
||||
rows.push(...result.items);
|
||||
totalCount = result.totalCount;
|
||||
if (result.items.length === 0) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal file
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet, apiPost } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapWorkOrderUplift,
|
||||
mapWorkOrderUplifts,
|
||||
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
function extractUpliftItems(data: unknown): unknown[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
const record = typeof data === "object" && data != null ? (data as Record<string, unknown>) : {};
|
||||
const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts;
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export const workOrderUpliftsApi = {
|
||||
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
|
||||
const unwrapped = handleApiResponse<unknown>(data);
|
||||
return mapWorkOrderUplifts(extractUpliftItems(unwrapped));
|
||||
},
|
||||
|
||||
create: async (
|
||||
workOrderId: string | number,
|
||||
input: CreateWorkOrderUpliftInput,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), {
|
||||
amount: input.amount,
|
||||
notes: input.notes,
|
||||
});
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
|
||||
cancel: async (
|
||||
workOrderId: string | number,
|
||||
upliftId: string | number,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.workOrder.upliftCancel(workOrderId, upliftId),
|
||||
{},
|
||||
);
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
|
||||
revoke: async (
|
||||
workOrderId: string | number,
|
||||
upliftId: string | number,
|
||||
reason?: string,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), {
|
||||
reason,
|
||||
});
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||
import {
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API_TO_FE,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
import type {
|
||||
CompletionDocStatus,
|
||||
WorkOrderTableRow,
|
||||
WorkOrderUpliftSummary,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
|
@ -215,6 +217,55 @@ function readFlagColor(record: Record<string, unknown>): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function readUpliftSummary(item: Record<string, unknown>): WorkOrderUpliftSummary | undefined {
|
||||
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
|
||||
const hasNested = Object.keys(nested).length > 0;
|
||||
const source = hasNested ? nested : item;
|
||||
|
||||
// Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present.
|
||||
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||
const nestedCount = readNumber(source, "pendingCount", "PendingCount");
|
||||
const pendingCount = nestedCount ?? boardCount ?? 0;
|
||||
const hasUplift =
|
||||
readBool(source, "hasUplift", "HasUplift") ||
|
||||
readBool(item, "hasUplift", "HasUplift") ||
|
||||
pendingCount > 0;
|
||||
|
||||
// No nested summary and no board count → omit (legacy payloads without the contract field).
|
||||
if (!hasNested && boardCount == null && !hasUplift) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary: WorkOrderUpliftSummary = {
|
||||
hasUplift: hasUplift || pendingCount > 0,
|
||||
pendingCount,
|
||||
};
|
||||
const primaryStatus =
|
||||
readString(nested, "primaryStatus", "PrimaryStatus") ||
|
||||
readString(item, "primaryStatus", "PrimaryStatus");
|
||||
if (primaryStatus) {
|
||||
summary.primaryStatus = primaryStatus;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function readHasPendingUplift(
|
||||
item: Record<string, unknown>,
|
||||
upliftSummary: WorkOrderUpliftSummary | undefined,
|
||||
): boolean | undefined {
|
||||
if ("hasPendingUplift" in item || "HasPendingUplift" in item) {
|
||||
return Boolean(item.hasPendingUplift ?? item.HasPendingUplift);
|
||||
}
|
||||
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||
if (boardCount != null) {
|
||||
return boardCount > 0;
|
||||
}
|
||||
if (upliftSummary != null) {
|
||||
return upliftSummary.pendingCount > 0;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||
const item = asRecord(raw);
|
||||
const scheduledOn = toDateInputValue(
|
||||
|
|
@ -269,6 +320,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||
const boardUpliftSummary = readUpliftSummary(item);
|
||||
const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
|
||||
const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
|
||||
|
||||
const row: WorkOrderTableRow = {
|
||||
id: readId(item),
|
||||
|
|
@ -279,7 +333,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
dispatcherId,
|
||||
dispatcherName,
|
||||
scheduledOn,
|
||||
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
||||
completedDate: toDateInputValue(
|
||||
readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
|
||||
),
|
||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||
dayGroup,
|
||||
dayLabel,
|
||||
|
|
@ -316,6 +372,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||
flagColor: readFlagColor(item),
|
||||
upliftSummary,
|
||||
hasPendingUplift,
|
||||
};
|
||||
|
||||
return row;
|
||||
|
|
|
|||
158
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal file
158
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import type {
|
||||
UpliftSummary,
|
||||
WorkOrderUplift,
|
||||
WorkOrderUpliftStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readOptionalId(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATUS_SET = new Set<string>(WORK_ORDER_UPLIFT_STATUSES);
|
||||
|
||||
function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus {
|
||||
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
if (STATUS_SET.has(normalized)) {
|
||||
return normalized as WorkOrderUpliftStatus;
|
||||
}
|
||||
if (normalized === "canceled") {
|
||||
return "cancelled";
|
||||
}
|
||||
if (normalized === "autoapproved") {
|
||||
return "auto_approved";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function mapUpliftSummary(raw: unknown): UpliftSummary {
|
||||
const item = asRecord(raw);
|
||||
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
|
||||
const hasNested = Object.keys(nested).length > 0;
|
||||
const source = hasNested ? nested : item;
|
||||
|
||||
const pendingCount =
|
||||
readNumber(source, "pendingCount", "PendingCount") ??
|
||||
readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ??
|
||||
0;
|
||||
const hasUplift =
|
||||
readBool(source, "hasUplift", "HasUplift") ||
|
||||
readBool(item, "hasUplift", "HasUplift") ||
|
||||
pendingCount > 0;
|
||||
|
||||
// Never treat the work-order lifecycle status/Status as uplift primaryStatus.
|
||||
const primaryStatusRaw = hasNested
|
||||
? readString(source, "primaryStatus", "PrimaryStatus", "status", "Status")
|
||||
: readString(item, "primaryStatus", "PrimaryStatus");
|
||||
const amount = hasNested
|
||||
? readNumber(source, "amount", "Amount")
|
||||
: readNumber(item, "amount", "Amount");
|
||||
|
||||
const summary: UpliftSummary = {
|
||||
hasUplift,
|
||||
pendingCount,
|
||||
};
|
||||
|
||||
if (primaryStatusRaw) {
|
||||
summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw);
|
||||
}
|
||||
if (amount != null) {
|
||||
summary.amount = amount;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
|
||||
const item = asRecord(raw);
|
||||
const statusRaw = readString(item, "status", "Status");
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending",
|
||||
amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0,
|
||||
notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"),
|
||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||
requestedById: readOptionalId(
|
||||
item,
|
||||
"requestedById",
|
||||
"RequestedById",
|
||||
"requesterId",
|
||||
"RequesterId",
|
||||
),
|
||||
requestedByName: readString(
|
||||
item,
|
||||
"requestedByName",
|
||||
"RequestedByName",
|
||||
"requestedByVendorName",
|
||||
"RequestedByVendorName",
|
||||
),
|
||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] {
|
||||
return rawItems.map(mapWorkOrderUplift);
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
import type { UpliftFilterStatus } from "./work-order-uplift-filter";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
WO_TYPE_META,
|
||||
|
|
@ -26,11 +28,19 @@ export interface WorkOrderAdvancedFilters {
|
|||
customTo: string;
|
||||
sites: Set<string>;
|
||||
types: Set<WOType>;
|
||||
severities: Set<WorkOrderSeverity>;
|
||||
dispatchers: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
pmTypes: Set<string>;
|
||||
vendorTechs: Set<string>;
|
||||
docs: Set<CompletionDocFilter>;
|
||||
flagColors: Set<string>;
|
||||
internalOnly: boolean;
|
||||
hasUplift: boolean;
|
||||
upliftStatuses: Set<UpliftFilterStatus>;
|
||||
}
|
||||
|
||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||
|
|
@ -39,11 +49,19 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
customTo: "",
|
||||
sites: new Set(),
|
||||
types: new Set(),
|
||||
severities: new Set(),
|
||||
dispatchers: new Set(),
|
||||
statuses: new Set(),
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
pmTypes: new Set(),
|
||||
vendorTechs: new Set(),
|
||||
docs: new Set(),
|
||||
flagColors: new Set(),
|
||||
internalOnly: false,
|
||||
hasUplift: false,
|
||||
upliftStatuses: new Set(),
|
||||
};
|
||||
|
||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
||||
|
|
@ -71,4 +89,11 @@ export const WO_TYPE_DESCRIPTIONS: Record<WOType, string> = Object.fromEntries(
|
|||
export { ALL_WIZARD_STATUSES, WO_TYPES };
|
||||
|
||||
export type AdvancedFilterSetKey =
|
||||
"sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";
|
||||
| "sites"
|
||||
| "types"
|
||||
| "dispatchers"
|
||||
| "statuses"
|
||||
| "pmTypes"
|
||||
| "vendorTechs"
|
||||
| "docs"
|
||||
| "flagColors";
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = {
|
|||
|
||||
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
||||
|
||||
/** Non-terminal lifecycle statuses for the global Unassigned queue (SH-165). */
|
||||
export const NON_TERMINAL_LIFECYCLE_STATUSES: LifecycleStatusApi[] = [
|
||||
LIFECYCLE_STATUS_API.Incomplete,
|
||||
LIFECYCLE_STATUS_API.Pending,
|
||||
LIFECYCLE_STATUS_API.Scheduled,
|
||||
LIFECYCLE_STATUS_API["En Route"],
|
||||
LIFECYCLE_STATUS_API["On Site"],
|
||||
LIFECYCLE_STATUS_API["In Progress"],
|
||||
LIFECYCLE_STATUS_API.Rescheduled,
|
||||
LIFECYCLE_STATUS_API["Pending Quote"],
|
||||
];
|
||||
|
||||
/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */
|
||||
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||
|
|
@ -179,6 +191,11 @@ export interface WorkOrderBoardRowApi {
|
|||
primaryDispatchId?: number | null;
|
||||
rowVersion?: string | null;
|
||||
dispatchRowVersion?: string | null;
|
||||
/**
|
||||
* Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract).
|
||||
* Drives the Completed closability gate.
|
||||
*/
|
||||
pendingUpliftCount?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCountsApi {
|
||||
|
|
@ -251,6 +268,14 @@ export interface WorkOrderBoardSearchQueryParams {
|
|||
pmTypes?: string[];
|
||||
vendorIds?: number[];
|
||||
docStatuses?: number[];
|
||||
severities?: number[];
|
||||
rescheduled?: boolean;
|
||||
carriedOver?: boolean;
|
||||
addOn?: boolean;
|
||||
flagColors?: string[];
|
||||
internalOnly?: boolean;
|
||||
hasUplift?: boolean;
|
||||
upliftStatuses?: string[];
|
||||
myWorkOrders?: boolean;
|
||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||
sortDir?: "asc" | "desc" | string;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
import type { UpliftSummary } from "./work-order-uplift";
|
||||
|
||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||
|
||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||
|
||||
export type WorkOrderUpliftSummary = {
|
||||
hasUplift: boolean;
|
||||
pendingCount: number;
|
||||
primaryStatus?: string;
|
||||
};
|
||||
|
||||
export interface WorkOrderTableRow {
|
||||
id: string | number;
|
||||
woNumber: string;
|
||||
|
|
@ -15,7 +22,7 @@ export interface WorkOrderTableRow {
|
|||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
scheduledOn: string;
|
||||
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
|
||||
/** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
|
||||
completedDate: string;
|
||||
targetWeek: string;
|
||||
dayGroup: string;
|
||||
|
|
@ -59,6 +66,8 @@ export interface WorkOrderTableRow {
|
|||
dispatchRowVersion: string;
|
||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||
flagColor: string | null;
|
||||
upliftSummary?: UpliftSummary | null;
|
||||
hasPendingUplift?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkOrderTableDraft {
|
||||
|
|
@ -130,3 +139,4 @@ export type WorkOrderDayGroup = {
|
|||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||
|
||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
||||
export const UNASSIGNED_DAY_KEY = "__unassigned__" as const;
|
||||
|
|
|
|||
16
src/domain/work-orders/types/work-order-uplift-filter.ts
Normal file
16
src/domain/work-orders/types/work-order-uplift-filter.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Uplift statuses exposed in advanced search (excludes cancelled/revoked). */
|
||||
export type UpliftFilterStatus = "pending" | "approved" | "auto_approved" | "rejected";
|
||||
|
||||
export const UPLIFT_FILTER_STATUSES: UpliftFilterStatus[] = [
|
||||
"pending",
|
||||
"approved",
|
||||
"auto_approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
export const UPLIFT_FILTER_LABELS: Record<UpliftFilterStatus, string> = {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
auto_approved: "Auto-approved",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
41
src/domain/work-orders/types/work-order-uplift.ts
Normal file
41
src/domain/work-orders/types/work-order-uplift.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export const WORK_ORDER_UPLIFT_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"auto_approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
"revoked",
|
||||
] as const;
|
||||
|
||||
export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number];
|
||||
|
||||
export interface UpliftSummary {
|
||||
hasUplift: boolean;
|
||||
pendingCount: number;
|
||||
primaryStatus?: WorkOrderUpliftStatus;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderUplift {
|
||||
id: string | number;
|
||||
status: WorkOrderUpliftStatus;
|
||||
amount: number;
|
||||
notes: string;
|
||||
requestedAt: string;
|
||||
requestedById: string | number | null;
|
||||
requestedByName: string;
|
||||
decidedAt: string;
|
||||
decidedByName: string;
|
||||
decisionNote: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkOrderUpliftInput {
|
||||
amount: number;
|
||||
notes: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
|
||||
hasUplift: false,
|
||||
pendingCount: 0,
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal file
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
function invalidateUpliftQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workOrderId: string | number,
|
||||
): void {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
export function useWorkOrderUplifts(
|
||||
workOrderId: string | number | null | undefined,
|
||||
): UseQueryResult<WorkOrderUplift[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
|
||||
queryFn: () =>
|
||||
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
|
||||
enabled: workOrderId != null && workOrderId !== "",
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, CreateWorkOrderUpliftInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateWorkOrderUpliftInput) =>
|
||||
workOrderUpliftsApi.create(workOrderId, input),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift request created");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, string | number> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift cancelled");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to cancel uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, { upliftId: string | number; reason?: string }> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) =>
|
||||
workOrderUpliftsApi.revoke(workOrderId, upliftId, reason),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift revoked");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to revoke uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type {
|
||||
WorkOrdersListParams,
|
||||
|
|
@ -10,6 +11,7 @@ import type {
|
|||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useWorkOrdersList(
|
||||
|
|
@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch(
|
|||
});
|
||||
}
|
||||
|
||||
export function useWorkOrdersUnassignedQueue(
|
||||
enabled = true,
|
||||
): UseQueryResult<WorkOrderTableRow[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.unassignedQueue(),
|
||||
queryFn: fetchUnassignedBoardRows,
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.dispatchers(),
|
||||
|
|
|
|||
283
src/domain/work-orders/utils/advanced-filter-chip-builders.ts
Normal file
283
src/domain/work-orders/utils/advanced-filter-chip-builders.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||
import {
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
type CompletionDocFilter,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import {
|
||||
UPLIFT_FILTER_LABELS,
|
||||
type UpliftFilterStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||
import { MONTH_NAMES } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export interface AdvancedFilterChip {
|
||||
key: string;
|
||||
group: string;
|
||||
value?: string;
|
||||
count?: number;
|
||||
accent?: boolean;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
}
|
||||
|
||||
type ChipBuilderContext = {
|
||||
siteNameOf: (id: string) => string;
|
||||
dispatcherNameOf: (id: string) => string;
|
||||
};
|
||||
|
||||
type SetChipInput = {
|
||||
chips: AdvancedFilterChip[];
|
||||
key: string;
|
||||
group: string;
|
||||
set: Set<string>;
|
||||
nameOf: (value: string) => string;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
};
|
||||
|
||||
function pushSetChip(input: SetChipInput): void {
|
||||
const { chips, key, group, set, nameOf, clear } = input;
|
||||
if (!set.size) return;
|
||||
if (set.size <= 2) {
|
||||
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
|
||||
return;
|
||||
}
|
||||
chips.push({ key, group, count: set.size, clear });
|
||||
}
|
||||
|
||||
function fmtChipDate(iso: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
||||
}
|
||||
|
||||
function severityLabel(level: WorkOrderSeverity): string {
|
||||
return SEVERITY_LEVELS[level].label;
|
||||
}
|
||||
|
||||
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (filters.dateRange === "this-week") return;
|
||||
const value =
|
||||
filters.dateRange === "custom"
|
||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
||||
chips.push({
|
||||
key: "date",
|
||||
group: "Date",
|
||||
value,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dateRange: "this-week",
|
||||
customFrom: "",
|
||||
customTo: "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendSetChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
ctx: ChipBuilderContext,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "sites",
|
||||
group: "Site",
|
||||
set: filters.sites,
|
||||
nameOf: ctx.siteNameOf,
|
||||
clear: (current) => ({ ...current, sites: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "types",
|
||||
group: "Type of WO",
|
||||
set: filters.types as Set<string>,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, types: new Set() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (!filters.severities?.size) return;
|
||||
const clearSeverity = (current: WorkOrderAdvancedFilters) => ({
|
||||
...current,
|
||||
severities: new Set<WorkOrderSeverity>(),
|
||||
});
|
||||
if (filters.severities.size <= 2) {
|
||||
chips.push({
|
||||
key: "severity",
|
||||
group: "Severity",
|
||||
value: [...filters.severities].map(severityLabel).join(", "),
|
||||
clear: clearSeverity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chips.push({
|
||||
key: "severity",
|
||||
group: "Severity",
|
||||
count: filters.severities.size,
|
||||
clear: clearSeverity,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendDispatcherChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
ctx: ChipBuilderContext,
|
||||
) {
|
||||
const realDispatchers = new Set(
|
||||
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
|
||||
);
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "disp",
|
||||
group: "Assigned to",
|
||||
set: realDispatchers,
|
||||
nameOf: ctx.dispatcherNameOf,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dispatchers: new Set(
|
||||
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
|
||||
chips.push({
|
||||
key: "disp-unassigned",
|
||||
group: "Assigned to",
|
||||
value: "Unassigned",
|
||||
accent: true,
|
||||
clear: (current) => {
|
||||
const next = new Set(current.dispatchers);
|
||||
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
|
||||
return { ...current, dispatchers: next };
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendStatusPmVendorDocChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "status",
|
||||
group: "Status",
|
||||
set: filters.statuses as Set<string>,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "pm",
|
||||
group: "PM type",
|
||||
set: filters.pmTypes,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, pmTypes: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "vendor",
|
||||
group: "Vendor / tech",
|
||||
set: filters.vendorTechs,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, vendorTechs: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "doc",
|
||||
group: "Comp doc",
|
||||
set: filters.docs as Set<string>,
|
||||
nameOf: (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
|
||||
clear: (current) => ({ ...current, docs: new Set() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendIndicatorChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
if (filters.rescheduled) {
|
||||
chips.push({
|
||||
key: "rescheduled",
|
||||
group: "Indicator",
|
||||
value: "Rescheduled",
|
||||
clear: (current) => ({ ...current, rescheduled: false }),
|
||||
});
|
||||
}
|
||||
if (filters.carriedOver) {
|
||||
chips.push({
|
||||
key: "carriedover",
|
||||
group: "Indicator",
|
||||
value: "Carried Over",
|
||||
clear: (current) => ({ ...current, carriedOver: false }),
|
||||
});
|
||||
}
|
||||
if (filters.addOn) {
|
||||
chips.push({
|
||||
key: "addon",
|
||||
group: "Indicator",
|
||||
value: "Add-On",
|
||||
clear: (current) => ({ ...current, addOn: false }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendFlagAndInternalChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "flags",
|
||||
group: "Flag",
|
||||
set: filters.flagColors,
|
||||
nameOf: (v) => FLAG_COLORS.find((c) => c.value === v)?.name ?? v,
|
||||
clear: (current) => ({ ...current, flagColors: new Set() }),
|
||||
});
|
||||
|
||||
if (filters.internalOnly) {
|
||||
chips.push({
|
||||
key: "internal",
|
||||
group: "WO#",
|
||||
value: "Internal (SH)",
|
||||
clear: (current) => ({ ...current, internalOnly: false }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (!filters.hasUplift) return;
|
||||
const clearUplift = (current: WorkOrderAdvancedFilters) => ({
|
||||
...current,
|
||||
hasUplift: false,
|
||||
upliftStatuses: new Set<UpliftFilterStatus>(),
|
||||
});
|
||||
if (filters.upliftStatuses.size) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "uplift",
|
||||
group: "Uplift",
|
||||
set: filters.upliftStatuses as Set<string>,
|
||||
nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v,
|
||||
clear: clearUplift,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chips.push({
|
||||
key: "uplift",
|
||||
group: "Uplift",
|
||||
value: "Has uplift",
|
||||
clear: clearUplift,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,59 +1,60 @@
|
|||
import {
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
EMPTY_ADVANCED_FILTERS,
|
||||
type CompletionDocFilter,
|
||||
type DateRangeKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
MONTH_NAMES,
|
||||
addDaysIso,
|
||||
mondayOf,
|
||||
pad2,
|
||||
todayIso,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
appendDateChip,
|
||||
appendDispatcherChips,
|
||||
appendFlagAndInternalChips,
|
||||
appendIndicatorChips,
|
||||
appendSetChips,
|
||||
appendSeverityChip,
|
||||
appendStatusPmVendorDocChips,
|
||||
appendUpliftChip,
|
||||
type AdvancedFilterChip,
|
||||
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export interface AdvancedFilterChip {
|
||||
key: string;
|
||||
group: string;
|
||||
value?: string;
|
||||
count?: number;
|
||||
accent?: boolean;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
}
|
||||
export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||
|
||||
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
|
||||
return {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
...filters,
|
||||
sites: new Set(filters.sites),
|
||||
types: new Set(filters.types),
|
||||
severities: new Set(filters.severities ?? []),
|
||||
dispatchers: new Set(filters.dispatchers),
|
||||
statuses: new Set(filters.statuses),
|
||||
pmTypes: new Set(filters.pmTypes),
|
||||
vendorTechs: new Set(filters.vendorTechs),
|
||||
docs: new Set(filters.docs),
|
||||
flagColors: new Set(filters.flagColors ?? []),
|
||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||
let count = 0;
|
||||
if (filters.dateRange !== "this-week") count += 1;
|
||||
if (filters.sites.size) count += 1;
|
||||
if (filters.types.size) count += 1;
|
||||
if (filters.dispatchers.size) count += 1;
|
||||
if (filters.statuses.size) count += 1;
|
||||
if (filters.pmTypes.size) count += 1;
|
||||
if (filters.vendorTechs.size) count += 1;
|
||||
if (filters.docs.size) count += 1;
|
||||
return count;
|
||||
}
|
||||
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
|
||||
filters.dateRange !== "this-week",
|
||||
Boolean(filters.sites?.size),
|
||||
Boolean(filters.types?.size),
|
||||
Boolean(filters.severities?.size),
|
||||
Boolean(filters.dispatchers?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
Boolean(filters.pmTypes?.size),
|
||||
Boolean(filters.vendorTechs?.size),
|
||||
Boolean(filters.docs?.size),
|
||||
Boolean(filters.flagColors?.size),
|
||||
filters.internalOnly,
|
||||
filters.hasUplift,
|
||||
];
|
||||
|
||||
function fmtChipDate(iso: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function monthStart(y: number, m: number): string {
|
||||
|
|
@ -91,22 +92,6 @@ export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string
|
|||
}
|
||||
}
|
||||
|
||||
function setChip(
|
||||
chips: AdvancedFilterChip[],
|
||||
key: string,
|
||||
group: string,
|
||||
set: Set<string>,
|
||||
nameOf: (value: string) => string,
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
|
||||
) {
|
||||
if (!set.size) return;
|
||||
if (set.size <= 2) {
|
||||
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
|
||||
return;
|
||||
}
|
||||
chips.push({ key, group, count: set.size, clear });
|
||||
}
|
||||
|
||||
export function buildAdvancedFilterChips(
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
options?: {
|
||||
|
|
@ -115,100 +100,19 @@ export function buildAdvancedFilterChips(
|
|||
},
|
||||
): AdvancedFilterChip[] {
|
||||
const chips: AdvancedFilterChip[] = [];
|
||||
const siteNameOf = options?.siteNameOf ?? ((v) => v);
|
||||
const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
|
||||
const ctx = {
|
||||
siteNameOf: options?.siteNameOf ?? ((v: string) => v),
|
||||
dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v),
|
||||
};
|
||||
|
||||
if (filters.dateRange !== "this-week") {
|
||||
const value =
|
||||
filters.dateRange === "custom"
|
||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
||||
chips.push({
|
||||
key: "date",
|
||||
group: "Date",
|
||||
value,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dateRange: "this-week",
|
||||
customFrom: "",
|
||||
customTo: "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
|
||||
...current,
|
||||
sites: new Set(),
|
||||
}));
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"types",
|
||||
"Type of WO",
|
||||
filters.types as Set<string>,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, types: new Set() }),
|
||||
);
|
||||
|
||||
const realDispatchers = new Set(
|
||||
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
|
||||
);
|
||||
setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
|
||||
...current,
|
||||
dispatchers: new Set(
|
||||
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
|
||||
),
|
||||
}));
|
||||
|
||||
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
|
||||
chips.push({
|
||||
key: "disp-unassigned",
|
||||
group: "Assigned to",
|
||||
value: "Unassigned",
|
||||
accent: true,
|
||||
clear: (current) => {
|
||||
const next = new Set(current.dispatchers);
|
||||
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
|
||||
return { ...current, dispatchers: next };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"status",
|
||||
"Status",
|
||||
filters.statuses as Set<string>,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, statuses: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"pm",
|
||||
"PM type",
|
||||
filters.pmTypes,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, pmTypes: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"vendor",
|
||||
"Vendor / tech",
|
||||
filters.vendorTechs,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, vendorTechs: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"doc",
|
||||
"Comp doc",
|
||||
filters.docs as Set<string>,
|
||||
(v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
|
||||
(current) => ({ ...current, docs: new Set() }),
|
||||
);
|
||||
appendDateChip(chips, filters);
|
||||
appendSetChips(chips, filters, ctx);
|
||||
appendSeverityChip(chips, filters);
|
||||
appendDispatcherChips(chips, filters, ctx);
|
||||
appendStatusPmVendorDocChips(chips, filters);
|
||||
appendIndicatorChips(chips, filters);
|
||||
appendFlagAndInternalChips(chips, filters);
|
||||
appendUpliftChip(chips, filters);
|
||||
|
||||
return chips;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
EMPTY_ADVANCED_FILTERS,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import {
|
||||
BOARD_DISPATCHER_UNASSIGNED,
|
||||
DATE_RANGE_TO_PRESET,
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API,
|
||||
NON_TERMINAL_LIFECYCLE_STATUSES,
|
||||
type WorkOrderBoardQueryParams,
|
||||
type WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
|
@ -16,6 +20,18 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord
|
|||
|
||||
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
|
||||
|
||||
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
|
||||
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
||||
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
|
||||
|
||||
/**
|
||||
* Wide Custom window for SH-165. GET /board/search defaults omitted
|
||||
* `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would
|
||||
* hide scheduled-unassigned WOs outside the current week.
|
||||
*/
|
||||
export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01";
|
||||
export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31";
|
||||
|
||||
function toBoardDispatcherId(id: string): string {
|
||||
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
||||
return BOARD_DISPATCHER_UNASSIGNED;
|
||||
|
|
@ -77,6 +93,47 @@ function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefin
|
|||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function resolveVendorIds(
|
||||
vendorTechs: Iterable<string>,
|
||||
vendorIdsByCompanyName?: Map<string, number>,
|
||||
): number[] | undefined {
|
||||
if (!vendorIdsByCompanyName) return undefined;
|
||||
const vendorIds: number[] = [];
|
||||
for (const name of vendorTechs) {
|
||||
const id = vendorIdsByCompanyName.get(name);
|
||||
if (typeof id === "number") {
|
||||
vendorIds.push(id);
|
||||
}
|
||||
}
|
||||
return vendorIds.length > 0 ? vendorIds : undefined;
|
||||
}
|
||||
|
||||
function mapSearchFacetParams(
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
): Pick<
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
| "severities"
|
||||
| "rescheduled"
|
||||
| "carriedOver"
|
||||
| "addOn"
|
||||
| "flagColors"
|
||||
| "internalOnly"
|
||||
| "hasUplift"
|
||||
| "upliftStatuses"
|
||||
> {
|
||||
return {
|
||||
severities: filters.severities?.size ? [...filters.severities] : undefined,
|
||||
rescheduled: filters.rescheduled || undefined,
|
||||
carriedOver: filters.carriedOver || undefined,
|
||||
addOn: filters.addOn || undefined,
|
||||
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
|
||||
internalOnly: filters.internalOnly || undefined,
|
||||
hasUplift: filters.hasUplift || undefined,
|
||||
upliftStatuses:
|
||||
filters.hasUplift && filters.upliftStatuses?.size ? [...filters.upliftStatuses] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build GET /board query from weekly filter bar. */
|
||||
export function toBoardQueryParams(
|
||||
bar: WorkOrderBarFilters,
|
||||
|
|
@ -95,6 +152,31 @@ export function toBoardQueryParams(
|
|||
};
|
||||
}
|
||||
|
||||
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
|
||||
datePreset: WorkOrderBoardSearchQueryParams["datePreset"];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
} {
|
||||
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
|
||||
return {
|
||||
datePreset: "Custom",
|
||||
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
|
||||
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
|
||||
};
|
||||
}
|
||||
|
||||
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
|
||||
if (datePreset === "Custom") {
|
||||
return {
|
||||
datePreset,
|
||||
dateFrom: filters.customFrom || undefined,
|
||||
dateTo: filters.customTo || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { datePreset };
|
||||
}
|
||||
|
||||
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
|
||||
export function toBoardSearchQueryParams(input: {
|
||||
filters: WorkOrderAdvancedFilters;
|
||||
|
|
@ -107,48 +189,35 @@ export function toBoardSearchQueryParams(input: {
|
|||
vendorIdsByCompanyName?: Map<string, number>;
|
||||
}): WorkOrderBoardSearchQueryParams {
|
||||
const { filters } = input;
|
||||
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
|
||||
const date = resolveAdvancedSearchDate(filters);
|
||||
const pageSize = Math.min(
|
||||
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const vendorIds: number[] = [];
|
||||
if (input.vendorIdsByCompanyName && filters.vendorTechs.size > 0) {
|
||||
for (const name of filters.vendorTechs) {
|
||||
const id = input.vendorIdsByCompanyName.get(name);
|
||||
if (typeof id === "number") {
|
||||
vendorIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const typeFilters = mapWoTypeFilters(filters.types);
|
||||
const trimmedSearch = input.search?.trim();
|
||||
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
||||
|
||||
const params: WorkOrderBoardSearchQueryParams = {
|
||||
return {
|
||||
page: input.page ?? 0,
|
||||
pageSize,
|
||||
search:
|
||||
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
|
||||
datePreset,
|
||||
search,
|
||||
datePreset: date.datePreset,
|
||||
dateFrom: date.dateFrom,
|
||||
dateTo: date.dateTo,
|
||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||
vendorIds: vendorIds.length > 0 ? vendorIds : undefined,
|
||||
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
|
||||
docStatuses: mapDocStatuses(filters.docs),
|
||||
...mapSearchFacetParams(filters),
|
||||
sortBy: input.sortBy || "scheduledDate",
|
||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||
};
|
||||
|
||||
if (datePreset === "Custom") {
|
||||
params.dateFrom = filters.customFrom || undefined;
|
||||
params.dateTo = filters.customTo || undefined;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
|
||||
|
|
@ -175,3 +244,16 @@ export function buildBoardSearchParamsRecord(
|
|||
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||
return type === "Overdue";
|
||||
}
|
||||
|
||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
|
||||
return {
|
||||
page,
|
||||
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
|
||||
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||
datePreset: "Custom",
|
||||
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
|
||||
dateTo: UNASSIGNED_QUEUE_DATE_TO,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,78 @@ export function filterByBarAndSearch(
|
|||
});
|
||||
}
|
||||
|
||||
export function isUnassignedBoardRow(row: WorkOrderTableRow): boolean {
|
||||
if (TERMINAL_STATUSES.has(row.status)) {
|
||||
return false;
|
||||
}
|
||||
return !row.dispatcherId;
|
||||
}
|
||||
|
||||
export function partitionBoardRows(rows: WorkOrderTableRow[]): {
|
||||
unassigned: WorkOrderTableRow[];
|
||||
unscheduled: WorkOrderTableRow[];
|
||||
scheduled: WorkOrderTableRow[];
|
||||
} {
|
||||
const unassigned: WorkOrderTableRow[] = [];
|
||||
const remaining: WorkOrderTableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (isUnassignedBoardRow(row)) {
|
||||
unassigned.push(row);
|
||||
} else {
|
||||
remaining.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
const { scheduled, unscheduled } = splitScheduledUnscheduled(remaining);
|
||||
return { unassigned, unscheduled, scheduled };
|
||||
}
|
||||
|
||||
export function rowIdSet(rows: WorkOrderTableRow[]): Set<string> {
|
||||
return new Set(rows.map((row) => String(row.id)));
|
||||
}
|
||||
|
||||
/** Weekly board rows win on id overlap so PATCH state stays authoritative. */
|
||||
export function mergeWeeklyAndUnassignedQueue(
|
||||
weekly: WorkOrderTableRow[],
|
||||
queue: WorkOrderTableRow[],
|
||||
): WorkOrderTableRow[] {
|
||||
if (queue.length === 0) {
|
||||
return weekly;
|
||||
}
|
||||
const seen = rowIdSet(weekly);
|
||||
const extras = queue.filter((row) => !seen.has(String(row.id)));
|
||||
return extras.length === 0 ? weekly : [...weekly, ...extras];
|
||||
}
|
||||
|
||||
type ComposeBoardSectionsInput = {
|
||||
displayRows: WorkOrderTableRow[];
|
||||
weekRows: WorkOrderTableRow[];
|
||||
weeklyIds: ReadonlySet<string>;
|
||||
queueIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin is every non-terminal unassigned row (including out-of-week).
|
||||
* Day/Unscheduled groups stay week-scoped and never repeat pin ids.
|
||||
*/
|
||||
export function composeBoardSections(input: ComposeBoardSectionsInput): {
|
||||
unassigned: WorkOrderTableRow[];
|
||||
unscheduled: WorkOrderTableRow[];
|
||||
scheduled: WorkOrderTableRow[];
|
||||
} {
|
||||
const unassigned = input.displayRows.filter(isUnassignedBoardRow);
|
||||
const pinIds = rowIdSet(unassigned);
|
||||
const weekAssigned = input.weekRows.filter((row) => {
|
||||
const id = String(row.id);
|
||||
if (pinIds.has(id) || isUnassignedBoardRow(row)) {
|
||||
return false;
|
||||
}
|
||||
return input.weeklyIds.has(id) || !input.queueIds.has(id);
|
||||
});
|
||||
return { unassigned, ...splitScheduledUnscheduled(weekAssigned) };
|
||||
}
|
||||
|
||||
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
|
||||
scheduled: WorkOrderTableRow[];
|
||||
unscheduled: WorkOrderTableRow[];
|
||||
|
|
|
|||
130
src/domain/work-orders/utils/uplift-display-utils.ts
Normal file
130
src/domain/work-orders/utils/uplift-display-utils.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type {
|
||||
WorkOrderUplift,
|
||||
UpliftSummary,
|
||||
WorkOrderUpliftStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export function formatUpliftMoney(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "—";
|
||||
}
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
auto_approved: "Auto",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Cancelled",
|
||||
revoked: "Revoked",
|
||||
};
|
||||
|
||||
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
|
||||
return STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
||||
if (!summary.hasUplift && summary.pendingCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (summary.pendingCount > 0) {
|
||||
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
|
||||
}
|
||||
if (summary.primaryStatus) {
|
||||
const label = formatUpliftStatusLabel(summary.primaryStatus);
|
||||
if (summary.amount != null) {
|
||||
return `${label} ${formatUpliftMoney(summary.amount)}`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
if (summary.hasUplift) {
|
||||
return "Uplift";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
|
||||
background: string;
|
||||
color: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
|
||||
case "approved":
|
||||
return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
|
||||
case "auto_approved":
|
||||
return { background: "var(--color-primary-tint)", color: "var(--color-primary)" };
|
||||
case "rejected":
|
||||
return { background: "#FEE2E2", color: "#B91C1C" };
|
||||
case "cancelled":
|
||||
case "revoked":
|
||||
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
||||
default:
|
||||
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
|
||||
return status === "Completed" || status === "Canceled";
|
||||
}
|
||||
|
||||
export function canOpenUpliftsDialog(
|
||||
summary: UpliftSummary,
|
||||
status: WorkOrderTableRow["status"],
|
||||
): boolean {
|
||||
if (summary.hasUplift || summary.pendingCount > 0) {
|
||||
return true;
|
||||
}
|
||||
return !isWorkOrderUpliftsReadOnly(status);
|
||||
}
|
||||
|
||||
export function canRevokeWorkOrderUplift(
|
||||
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
|
||||
currentUserId: string | number | null | undefined,
|
||||
): boolean {
|
||||
if (uplift.status !== "auto_approved") {
|
||||
// SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
currentUserId != null &&
|
||||
currentUserId !== "" &&
|
||||
uplift.requestedById != null &&
|
||||
String(uplift.requestedById) === String(currentUserId)
|
||||
);
|
||||
}
|
||||
|
||||
export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
|
||||
return status === "approved";
|
||||
}
|
||||
|
||||
export const UPLIFT_STANDARD_ALLOWANCE_USD = 500;
|
||||
export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000;
|
||||
|
||||
export function getUpliftAllowanceCap(workOrderType: string): number {
|
||||
return workOrderType === "Emergency"
|
||||
? UPLIFT_EMERGENCY_ALLOWANCE_USD
|
||||
: UPLIFT_STANDARD_ALLOWANCE_USD;
|
||||
}
|
||||
|
||||
export function hasOpenWorkOrderUplift(
|
||||
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status">>,
|
||||
): boolean {
|
||||
return uplifts.some((uplift) => uplift.status === "pending");
|
||||
}
|
||||
|
||||
export function getRemainingUpliftAllowance(
|
||||
workOrderType: string,
|
||||
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status" | "amount">>,
|
||||
): number {
|
||||
const consumed = uplifts
|
||||
.filter((uplift) => uplift.status === "auto_approved")
|
||||
.reduce((sum, uplift) => sum + uplift.amount, 0);
|
||||
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
|
||||
}
|
||||
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type {
|
||||
CompletionDocStatus,
|
||||
WorkOrderUpliftSummary,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
|
||||
export type WoClosabilityInput = {
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
/** Company / vendor assignment — required for Completed (SH-183). */
|
||||
company: string;
|
||||
/** Technician is optional for closability; modeled separately from company. */
|
||||
tech: string;
|
||||
/** Work completed date (API CompletedDate). Empty until set. */
|
||||
completedDate: string;
|
||||
woNumber: string;
|
||||
pm: string;
|
||||
docStatus: CompletionDocStatus;
|
||||
mediaCount: number;
|
||||
type: WOType | "";
|
||||
upliftSummary?: WorkOrderUpliftSummary | null;
|
||||
hasPendingUplift?: boolean;
|
||||
};
|
||||
|
||||
export const PENDING_UPLIFT_GAP = "Pending uplift approval";
|
||||
|
||||
/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */
|
||||
export const UPLIFT_PENDING_COMPLETED_TITLE =
|
||||
"This work order has an uplift pending approval and cannot be completed.";
|
||||
|
||||
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
|
||||
export function isRealWoNumber(woNumber: string): boolean {
|
||||
const trimmed = woNumber.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return !/^SH/i.test(trimmed);
|
||||
}
|
||||
|
||||
export function hasPendingUplift(
|
||||
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
|
||||
): boolean {
|
||||
if (row.hasPendingUplift === true) {
|
||||
return true;
|
||||
}
|
||||
return (row.upliftSummary?.pendingCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
|
||||
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||
}
|
||||
|
||||
function hasCompany(company: string): boolean {
|
||||
return Boolean(company.trim());
|
||||
}
|
||||
|
||||
function hasService(pm: string): boolean {
|
||||
return Boolean(pm.trim());
|
||||
}
|
||||
|
||||
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
|
||||
return docStatus === "Yes" || docStatus === "NN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaps that block Completed, in design hierarchy:
|
||||
* fields → uplift pending → completion document / photos.
|
||||
* (Scheduled-date gate is a separate design P1 — not in this module yet.)
|
||||
*/
|
||||
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
||||
const gaps: string[] = [];
|
||||
|
||||
if (!hasDispatcher(row)) {
|
||||
gaps.push("Dispatcher");
|
||||
}
|
||||
if (!hasCompany(row.company)) {
|
||||
gaps.push("Company");
|
||||
}
|
||||
if (!row.completedDate.trim()) {
|
||||
gaps.push("Completed Date");
|
||||
}
|
||||
if (!isRealWoNumber(row.woNumber)) {
|
||||
gaps.push("WO number");
|
||||
}
|
||||
if (!hasService(row.pm)) {
|
||||
gaps.push("Service");
|
||||
}
|
||||
|
||||
// Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion.
|
||||
// Missing uplift fields mean no pending evidence until the board contract supplies the count.
|
||||
if (hasPendingUplift(row)) {
|
||||
gaps.push(PENDING_UPLIFT_GAP);
|
||||
}
|
||||
|
||||
if (row.type === "PM" || row.type === "Overdue") {
|
||||
if (!hasCompletionDocument(row.docStatus)) {
|
||||
gaps.push("Completion document");
|
||||
}
|
||||
}
|
||||
|
||||
if (row.type === "Emergency" || row.type === "Reactive") {
|
||||
if (row.mediaCount < 1) {
|
||||
gaps.push("At least 1 photo");
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
export function formatClosabilityMissingTitle(gaps: string[]): string {
|
||||
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||
}
|
||||
return `Missing: ${gaps.join(", ")}`;
|
||||
}
|
||||
|
||||
export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string };
|
||||
|
||||
/**
|
||||
* Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this
|
||||
* before opening generate flow and surface `reason` when blocked.
|
||||
*
|
||||
* Intentionally omits the "Completion document" closability gap — generate is how that gap is filled.
|
||||
*/
|
||||
export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult {
|
||||
const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document");
|
||||
if (gaps.length > 0) {
|
||||
return { allowed: false, reason: formatClosabilityMissingTitle(gaps) };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/** Map create-wizard draft onto the shared closability contract. */
|
||||
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
|
||||
return {
|
||||
dispatcherId: draft.assigneeId,
|
||||
dispatcherName: draft.assigneeName,
|
||||
company: draft.vendorName,
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: draft.woNumber,
|
||||
pm: draft.pm,
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: draft.type,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow {
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 1,
|
||||
woNumber: "1001",
|
||||
site: "BK5",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-15",
|
||||
completedDate: "2026-07-20",
|
||||
targetWeek: "2026-07-15",
|
||||
dayGroup: "07/15",
|
||||
dayLabel: "Tue",
|
||||
location: "Lobby",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "7",
|
||||
company: "Acme",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "In Progress",
|
||||
docStatus: "Yes",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
isAddOn: false,
|
||||
mediaCount: 1,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("StatusCell pending uplift closability", () => {
|
||||
it("disables Completed with tooltip when uplift is pending", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ hasPendingUplift: true })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute(
|
||||
"title",
|
||||
"This work order has an uplift pending approval and cannot be completed.",
|
||||
);
|
||||
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({
|
||||
hasPendingUplift: true,
|
||||
upliftSummary: { hasUplift: true, pendingCount: 1 },
|
||||
})}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute(
|
||||
"title",
|
||||
"This work order has an uplift pending approval and cannot be completed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows Completed when no pending uplift", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell wo={makeRow()} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
|
||||
it("disables Completed when company is missing (SH-183)", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Completed when technician is empty if company is set (SH-183)", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ tech: "" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
});
|
||||
|
|
@ -60,6 +60,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
buildDayGroups,
|
||||
composeBoardSections,
|
||||
rowIdSet,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: "1",
|
||||
woNumber: "24991",
|
||||
site: "CLV2",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "2026-06-02",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "06/02",
|
||||
dayLabel: "TUE · 06/02",
|
||||
location: "Cleveland, OH",
|
||||
locationId: "",
|
||||
pm: "Backflow",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "—",
|
||||
status: "Incomplete",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
vendorId: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseTableData(
|
||||
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
|
||||
): ReturnTypeOfUseWorkOrdersTableData {
|
||||
return {
|
||||
rows: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
advActive: false,
|
||||
advResults: [],
|
||||
unassigned: [],
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled: [],
|
||||
displayGroups: [],
|
||||
searchActive: false,
|
||||
noSearchResults: false,
|
||||
isEmptyWeek: false,
|
||||
todayKey: null,
|
||||
draftDayKey: null,
|
||||
draft: null,
|
||||
setDraft: vi.fn(),
|
||||
unassignedOpen: true,
|
||||
setUnassignedOpen: vi.fn(),
|
||||
unschedOpen: true,
|
||||
setUnschedOpen: vi.fn(),
|
||||
selectedId: null,
|
||||
setSelectedId: vi.fn(),
|
||||
selectedRow: null,
|
||||
flashId: null,
|
||||
triggerFlash: vi.fn(),
|
||||
dragViz: null,
|
||||
setDragViz: vi.fn(),
|
||||
dragState: null,
|
||||
patchRow: vi.fn(),
|
||||
clearRowPatch: vi.fn(),
|
||||
reorderRows: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
startDayDraft: vi.fn(),
|
||||
cancelDraft: vi.fn(),
|
||||
addRow: vi.fn(),
|
||||
scopeTotalCount: 0,
|
||||
totalCount: 0,
|
||||
search: "",
|
||||
...overrides,
|
||||
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||
}
|
||||
|
||||
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
|
||||
|
||||
function UnassignedAssignHarness({
|
||||
initialRow,
|
||||
patchBoardField,
|
||||
}: {
|
||||
initialRow: WorkOrderTableRow;
|
||||
patchBoardField: (
|
||||
id: string | number,
|
||||
payload: { field: string; value?: string | null },
|
||||
) => Promise<WorkOrderTableRow>;
|
||||
}) {
|
||||
const [rows, setRows] = useState([initialRow]);
|
||||
const weeklyIds = rowIdSet(rows);
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
composeBoardSections({
|
||||
displayRows: rows,
|
||||
weekRows: rows,
|
||||
weeklyIds,
|
||||
queueIds: weeklyIds,
|
||||
}),
|
||||
[rows, weeklyIds],
|
||||
);
|
||||
const tableData = baseTableData({
|
||||
unassigned: sections.unassigned,
|
||||
unscheduled: sections.unscheduled,
|
||||
displayGroups: buildDayGroups(sections.scheduled),
|
||||
});
|
||||
|
||||
return (
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
<td>
|
||||
<AssignedToCell
|
||||
dispatcherId={row.dispatcherId}
|
||||
users={[DISPATCHER]}
|
||||
highlight={!row.dispatcherId}
|
||||
onSave={async (id, name) => {
|
||||
const ops = expandTablePatchToBoardFieldOps({
|
||||
dispatcherId: id,
|
||||
dispatcherName: name,
|
||||
});
|
||||
const patched = await patchBoardField(row.id, ops[0]!);
|
||||
setRows((current) =>
|
||||
current.map((item) =>
|
||||
String(item.id) === String(row.id)
|
||||
? { ...patched, dispatcherId: id, dispatcherName: name }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||
it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
|
||||
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
||||
const undatedUnassigned = makeRow({
|
||||
id: "us1",
|
||||
woNumber: "25021",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const undatedAssigned = makeRow({
|
||||
id: "us-assigned",
|
||||
woNumber: "25022",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const datedAssigned = makeRow({
|
||||
id: "day-assigned",
|
||||
woNumber: "25023",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
});
|
||||
|
||||
const tableData = baseTableData({
|
||||
unassigned: [datedUnassigned, undatedUnassigned],
|
||||
unscheduled: [undatedAssigned],
|
||||
displayGroups: [
|
||||
{
|
||||
key: "06/02",
|
||||
label: "TUE · 06/02",
|
||||
date: "2026-06-02",
|
||||
items: [datedAssigned],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
const unassignedHeader = screen.getByRole("button", {
|
||||
name: "Unassigned, 2 work orders, expanded",
|
||||
});
|
||||
const unscheduledHeader = screen.getByRole("button", {
|
||||
name: "Unscheduled, 1 work order, expanded",
|
||||
});
|
||||
const dayLabel = screen.getByText("TUE · 06/02");
|
||||
|
||||
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("collapses and expands the Unassigned section via the disclosure control", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setUnassignedOpen = vi.fn();
|
||||
const tableData = baseTableData({
|
||||
unassigned: [makeRow({ id: "u1", woNumber: "24991" })],
|
||||
setUnassignedOpen,
|
||||
});
|
||||
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
|
||||
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
|
||||
const outOfWeek = makeRow({
|
||||
id: "out-week",
|
||||
woNumber: "26001",
|
||||
scheduledOn: "2026-07-15",
|
||||
dayGroup: "07/15",
|
||||
dayLabel: "WED · 07/15",
|
||||
});
|
||||
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={baseTableData({ unassigned: [outOfWeek] })}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
|
||||
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("distinguishes a still-loading queue from an empty one", () => {
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={baseTableData({ unassignedLoading: true })}
|
||||
renderRow={() => null}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces an empty queue only once loading has settled", () => {
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={baseTableData({ unassignedLoading: false })}
|
||||
renderRow={() => null}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Unassigned pin visible when the queue query fails", () => {
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={baseTableData({
|
||||
unassignedError: new Error("queue unavailable"),
|
||||
})}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
|
||||
render(
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={baseTableData()}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
</tr>
|
||||
)}
|
||||
renderInlineRow={() => null}
|
||||
/>
|
||||
</table>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes a pinned row immediately after assigning through the board PATCH path", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pinned = makeRow({
|
||||
id: "u-pin",
|
||||
woNumber: "24991",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
rowVersion: "AAAAAAA=",
|
||||
});
|
||||
const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => {
|
||||
expect(payload.field).toBe("assignTo");
|
||||
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
|
||||
});
|
||||
|
||||
render(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
|
||||
|
||||
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Select/ }));
|
||||
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
|
||||
|
||||
expect(patchBoardField).toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell";
|
||||
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
|
||||
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const pendingUplift: WorkOrderUplift = {
|
||||
id: 1,
|
||||
status: "pending",
|
||||
amount: 450,
|
||||
notes: "Extra work",
|
||||
requestedAt: "2026-07-20T10:00:00Z",
|
||||
requestedById: "vendor-1",
|
||||
requestedByName: "Vendor",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
};
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
uplifts: [] as WorkOrderUplift[],
|
||||
}));
|
||||
|
||||
const baseRow: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-20",
|
||||
completedDate: "",
|
||||
targetWeek: "2026-07-20",
|
||||
dayGroup: "2026-07-20",
|
||||
dayLabel: "Mon",
|
||||
location: "Chicago Loop",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "1",
|
||||
company: "Vendor Co",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "v1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 },
|
||||
};
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
|
||||
useWorkOrderUplifts: () => ({
|
||||
data: mockState.uplifts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
describe("UpliftCell affordances", () => {
|
||||
it("renders pending pill and opens handler when onOpen is provided", () => {
|
||||
const onOpen = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<UpliftCell summary={{ hasUplift: true, pendingCount: 1 }} onOpen={onOpen} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 pending/i }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows read-only dash when onOpen is omitted", () => {
|
||||
renderWithProviders(<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkOrderUpliftsDialog affordances", () => {
|
||||
it("hides create form when a pending request already exists", () => {
|
||||
mockState.uplifts = [pendingUplift];
|
||||
|
||||
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
|
||||
withAuth: true,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows create form with remaining allowance when no pending request exists", () => {
|
||||
mockState.uplifts = [];
|
||||
|
||||
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
|
||||
withAuth: true,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides create and cancel actions when work order is completed", () => {
|
||||
mockState.uplifts = [pendingUplift];
|
||||
|
||||
renderWithProviders(
|
||||
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
|
||||
{ withAuth: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkOrderUpliftListItem revoke affordances", () => {
|
||||
it("hides Revoke for admin-approved uplifts on the Work Order surface", () => {
|
||||
renderWithProviders(
|
||||
<WorkOrderUpliftListItem
|
||||
uplift={{ ...pendingUplift, status: "approved" }}
|
||||
readOnly={false}
|
||||
currentUserId="admin-1"
|
||||
onRevoke={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => {
|
||||
renderWithProviders(
|
||||
<WorkOrderUpliftListItem
|
||||
uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }}
|
||||
readOnly={false}
|
||||
currentUserId="dispatcher-1"
|
||||
onRevoke={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
|
||||
describe("WorkOrdersAdvancedFiltersBanner count semantics", () => {
|
||||
it("labels the banner with search totalCount, not the current page length", () => {
|
||||
render(
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={{
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
severities: new Set([1]),
|
||||
hasUplift: true,
|
||||
}}
|
||||
totalCount={150}
|
||||
sites={[]}
|
||||
users={[]}
|
||||
onClearChip={() => undefined}
|
||||
onClearAll={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("150 work orders")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,10 @@ import { fireEvent, screen, within } from "@testing-library/react";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
toBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const setAdvOpen = vi.fn();
|
||||
|
|
@ -55,8 +59,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
|||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||
useWorkOrdersTableData: () => ({
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
scopeTotalCount: 0,
|
||||
totalCount: advApplied ? 42 : 0,
|
||||
scopeTotalCount: advApplied ? 42 : 0,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
setSelectedId: vi.fn(),
|
||||
|
|
@ -102,10 +106,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
|
|||
WoTable: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({
|
||||
WorkOrdersFilterBar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
|
||||
MediaUploadModal: () => null,
|
||||
}));
|
||||
|
|
@ -158,4 +158,113 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
);
|
||||
expect(clearAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies Severity and Has uplift through the public Advanced Filters sheet", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Severity/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /SEV 1/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Uplift/i }));
|
||||
fireEvent.click(within(sheet).getByLabelText(/Has uplift/i));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||
|
||||
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||
expect(applied.severities.has(1)).toBe(true);
|
||||
expect(applied.hasUplift).toBe(true);
|
||||
expect(applied.upliftStatuses.has("pending")).toBe(true);
|
||||
|
||||
const params = toBoardSearchQueryParams({ filters: applied });
|
||||
expect(params.page).toBe(0);
|
||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
expect(params.severities).toEqual([1]);
|
||||
expect(params.hasUplift).toBe(true);
|
||||
expect(params.upliftStatuses).toEqual(["pending"]);
|
||||
expect(params.datePreset).toBe("Custom");
|
||||
expect(params.dateFrom).toBe("2000-01-01");
|
||||
expect(params.dateTo).toBe("2099-12-31");
|
||||
});
|
||||
|
||||
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Indicators/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Work Order Number/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Internal numbers only/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||
|
||||
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||
expect(applied.rescheduled).toBe(true);
|
||||
expect(applied.carriedOver).toBe(true);
|
||||
expect(applied.addOn).toBe(true);
|
||||
expect(applied.flagColors.has("#EF4444")).toBe(true);
|
||||
expect(applied.internalOnly).toBe(true);
|
||||
|
||||
const params = toBoardSearchQueryParams({ filters: applied });
|
||||
expect(params.page).toBe(0);
|
||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
expect(params.rescheduled).toBe(true);
|
||||
expect(params.carriedOver).toBe(true);
|
||||
expect(params.addOn).toBe(true);
|
||||
expect(params.flagColors).toEqual(["#EF4444"]);
|
||||
expect(params.internalOnly).toBe(true);
|
||||
expect(params.datePreset).toBe("Custom");
|
||||
expect(params.dateFrom).toBe("2000-01-01");
|
||||
expect(params.dateTo).toBe("2099-12-31");
|
||||
});
|
||||
|
||||
it("disables dispatcher, week, and type bar while advanced filters are applied", () => {
|
||||
advApplied = { ...EMPTY_ADVANCED_FILTERS, rescheduled: true };
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
|
||||
expect(screen.getByText("42 work orders")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
|
||||
expect(clearAdvancedFilters).toHaveBeenCalled();
|
||||
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
expect(screen.queryByText("42 work orders")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "All" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "PM" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
const searchBoard = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
searchBoard: (...args: unknown[]) => searchBoard(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
||||
|
||||
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
|
||||
return {
|
||||
items,
|
||||
scheduled: [],
|
||||
unscheduled: [],
|
||||
totalCount,
|
||||
returnedCount: items.length,
|
||||
page: 0,
|
||||
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchUnassignedBoardRows", () => {
|
||||
beforeEach(() => {
|
||||
searchBoard.mockReset();
|
||||
});
|
||||
|
||||
it("pages until totalCount is exhausted", async () => {
|
||||
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
|
||||
id: `a-${index}`,
|
||||
}));
|
||||
const second = [{ id: "b-0" }];
|
||||
searchBoard
|
||||
.mockResolvedValueOnce(pageResult(first, 101))
|
||||
.mockResolvedValueOnce(pageResult(second, 101));
|
||||
|
||||
const rows = await fetchUnassignedBoardRows();
|
||||
|
||||
expect(searchBoard).toHaveBeenCalledTimes(2);
|
||||
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
|
||||
page: 0,
|
||||
dispatchers: ["__unassigned__"],
|
||||
datePreset: "Custom",
|
||||
});
|
||||
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
|
||||
expect(rows).toHaveLength(101);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isSlideOverDraftDirty,
|
||||
SLIDE_OVER_EDIT_KEYS,
|
||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
|
|
@ -52,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import {
|
||||
buildDayGroups,
|
||||
composeBoardSections,
|
||||
computeIsPastDue,
|
||||
enrichTableRow,
|
||||
filterByBarAndSearch,
|
||||
isUnassignedBoardRow,
|
||||
matchesAdvancedFilters,
|
||||
mergeWeeklyAndUnassignedQueue,
|
||||
partitionBoardRows,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
|
|
@ -63,6 +70,19 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "AQAAAAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function bar(
|
||||
weekMonday: string,
|
||||
overrides: Partial<WorkOrderBarFilters> = {},
|
||||
): WorkOrderBarFilters {
|
||||
return {
|
||||
weekMonday,
|
||||
dispatcherIds: new Set(),
|
||||
type: "All",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -76,6 +96,162 @@ describe("deriveDayFields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("filterByBarAndSearch", () => {
|
||||
const weekMonday = "2026-06-01";
|
||||
|
||||
it("keeps undated rows without targetWeek for any active week", () => {
|
||||
const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" });
|
||||
|
||||
const ids = filterByBarAndSearch([undated], bar(weekMonday), "").map((row) => row.id);
|
||||
expect(ids).toEqual(["us1"]);
|
||||
});
|
||||
|
||||
it("excludes undated rows whose targetWeek is outside the active week", () => {
|
||||
const undatedOtherTarget = makeRow({
|
||||
id: "tw1",
|
||||
scheduledOn: "",
|
||||
targetWeek: "2026-07-06",
|
||||
});
|
||||
|
||||
const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map(
|
||||
(row) => row.id,
|
||||
);
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes dated rows outside the active week", () => {
|
||||
const inWeek = makeRow({
|
||||
id: "in",
|
||||
scheduledOn: "2026-06-03",
|
||||
...deriveDayFields("2026-06-03"),
|
||||
});
|
||||
const outWeek = makeRow({
|
||||
id: "out",
|
||||
scheduledOn: "2026-06-15",
|
||||
...deriveDayFields("2026-06-15"),
|
||||
});
|
||||
|
||||
const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id);
|
||||
expect(ids).toEqual(["in"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnassignedBoardRow", () => {
|
||||
it("treats empty dispatcher as unassigned unless terminal", () => {
|
||||
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "" }))).toBe(true);
|
||||
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "u1" }))).toBe(false);
|
||||
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "", status: "Completed" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partitionBoardRows", () => {
|
||||
it("pins unassigned rows first and keeps assigned undated rows in Unscheduled", () => {
|
||||
const datedUnassigned = makeRow({
|
||||
id: "u-dated",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "2026-06-02",
|
||||
...deriveDayFields("2026-06-02"),
|
||||
});
|
||||
const undatedUnassigned = makeRow({
|
||||
id: "us-undated",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const undatedAssigned = makeRow({
|
||||
id: "us-assigned",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const datedAssigned = makeRow({ id: "day-assigned", dispatcherId: "sarah" });
|
||||
|
||||
const result = partitionBoardRows([
|
||||
datedUnassigned,
|
||||
undatedUnassigned,
|
||||
undatedAssigned,
|
||||
datedAssigned,
|
||||
]);
|
||||
|
||||
expect(result.unassigned.map((row) => row.id)).toEqual(["u-dated", "us-undated"]);
|
||||
expect(result.unscheduled.map((row) => row.id)).toEqual(["us-assigned"]);
|
||||
expect(result.scheduled.map((row) => row.id)).toEqual(["day-assigned"]);
|
||||
|
||||
const allIds = [...result.unassigned, ...result.unscheduled, ...result.scheduled].map(
|
||||
(row) => row.id,
|
||||
);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWeeklyAndUnassignedQueue", () => {
|
||||
it("keeps the weekly row when the same id is also in the queue", () => {
|
||||
const weekly = makeRow({ id: "dup", dispatcherName: "Weekly" });
|
||||
const queue = makeRow({ id: "dup", dispatcherName: "Queue" });
|
||||
const extra = makeRow({ id: "out", dispatcherId: "", dispatcherName: "" });
|
||||
|
||||
const merged = mergeWeeklyAndUnassignedQueue([weekly], [queue, extra]);
|
||||
expect(merged.map((row) => row.id)).toEqual(["dup", "out"]);
|
||||
expect(merged[0]?.dispatcherName).toBe("Weekly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeBoardSections", () => {
|
||||
it("pins a scheduled unassigned WO that is outside the selected week", () => {
|
||||
const inWeekAssigned = makeRow({
|
||||
id: "in-week",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "2026-06-03",
|
||||
...deriveDayFields("2026-06-03"),
|
||||
});
|
||||
const outOfWeekUnassigned = makeRow({
|
||||
id: "out-week",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "2026-07-15",
|
||||
...deriveDayFields("2026-07-15"),
|
||||
});
|
||||
|
||||
const result = composeBoardSections({
|
||||
displayRows: [inWeekAssigned, outOfWeekUnassigned],
|
||||
weekRows: [inWeekAssigned],
|
||||
weeklyIds: new Set(["in-week"]),
|
||||
queueIds: new Set(["out-week"]),
|
||||
});
|
||||
|
||||
expect(result.unassigned.map((row) => row.id)).toEqual(["out-week"]);
|
||||
expect(result.scheduled.map((row) => row.id)).toEqual(["in-week"]);
|
||||
expect(result.unscheduled).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not place an assigned leftover queue row into the current week groups", () => {
|
||||
const assignedOutOfWeek = makeRow({
|
||||
id: "was-queue",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "2026-07-15",
|
||||
...deriveDayFields("2026-07-15"),
|
||||
});
|
||||
|
||||
const result = composeBoardSections({
|
||||
displayRows: [assignedOutOfWeek],
|
||||
weekRows: [assignedOutOfWeek],
|
||||
weeklyIds: new Set(),
|
||||
queueIds: new Set(["was-queue"]),
|
||||
});
|
||||
|
||||
expect(result.unassigned).toEqual([]);
|
||||
expect(result.scheduled).toEqual([]);
|
||||
expect(result.unscheduled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitScheduledUnscheduled", () => {
|
||||
it("splits rows without scheduledOn into unscheduled", () => {
|
||||
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
PENDING_UPLIFT_GAP,
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
canGenerateCompDoc,
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
hasPendingUplift,
|
||||
isRealWoNumber,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
|
||||
return {
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
company: "Acme HVAC",
|
||||
tech: "Sam",
|
||||
completedDate: "2026-07-01",
|
||||
woNumber: "1001",
|
||||
pm: "HVAC",
|
||||
docStatus: "Yes",
|
||||
mediaCount: 1,
|
||||
type: "PM",
|
||||
hasPendingUplift: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isRealWoNumber", () => {
|
||||
it("rejects empty and SH-prefixed provisional numbers", () => {
|
||||
expect(isRealWoNumber("")).toBe(false);
|
||||
expect(isRealWoNumber("SH-9")).toBe(false);
|
||||
expect(isRealWoNumber("sh123")).toBe(false);
|
||||
expect(isRealWoNumber("98765")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPendingUplift", () => {
|
||||
it("is true when hasPendingUplift flag is set", () => {
|
||||
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when upliftSummary.pendingCount is positive", () => {
|
||||
expect(
|
||||
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when uplift is explicitly not pending", () => {
|
||||
expect(hasPendingUplift(makeInput())).toBe(false);
|
||||
expect(
|
||||
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClosabilityGaps", () => {
|
||||
it("returns empty when all gates are satisfied", () => {
|
||||
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows company without technician (SH-183)", () => {
|
||||
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
||||
expect(gaps).toEqual([]);
|
||||
expect(gaps).not.toContain("Technician");
|
||||
});
|
||||
|
||||
it("blocks Completed when company is missing even if technician is assigned", () => {
|
||||
expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]);
|
||||
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
|
||||
});
|
||||
|
||||
it("lists field gaps before uplift and document gaps", () => {
|
||||
expect(
|
||||
getClosabilityGaps(
|
||||
makeInput({
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: " ",
|
||||
tech: " ",
|
||||
completedDate: "",
|
||||
woNumber: "SH-1",
|
||||
pm: "",
|
||||
hasPendingUplift: true,
|
||||
docStatus: "No",
|
||||
type: "PM",
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"Dispatcher",
|
||||
"Company",
|
||||
"Completed Date",
|
||||
"WO number",
|
||||
"Service",
|
||||
PENDING_UPLIFT_GAP,
|
||||
"Completion document",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks Completed when uplift is pending via summary", () => {
|
||||
expect(
|
||||
getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })),
|
||||
).toEqual([PENDING_UPLIFT_GAP]);
|
||||
});
|
||||
|
||||
it("blocks Completed when hasPendingUplift is true", () => {
|
||||
expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]);
|
||||
});
|
||||
|
||||
it("allows Completed when uplift status fields are absent (no pending evidence)", () => {
|
||||
expect(
|
||||
getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires a photo for Emergency / Reactive", () => {
|
||||
expect(
|
||||
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
|
||||
).toEqual(["At least 1 photo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatClosabilityMissingTitle", () => {
|
||||
it("uses design Completed title when only uplift is missing", () => {
|
||||
expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe(
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses design Completed title when pending uplift is among mixed gaps", () => {
|
||||
expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe(
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
);
|
||||
});
|
||||
|
||||
it("joins multiple non-uplift gaps with Missing prefix", () => {
|
||||
expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe(
|
||||
"Missing: Dispatcher, Company",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canGenerateCompDoc", () => {
|
||||
it("allows generation when no closability gaps", () => {
|
||||
expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows generation when only the completion document is missing", () => {
|
||||
expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("blocks generation with design reason when only pending uplift exists", () => {
|
||||
expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({
|
||||
allowed: false,
|
||||
reason: UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/test/lib/auth/user-utils.test.ts
Normal file
15
src/test/lib/auth/user-utils.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
|
||||
describe("isAdminUser", () => {
|
||||
it("returns true when Admin appears in roles", () => {
|
||||
expect(isAdminUser("Admin")).toBe(true);
|
||||
expect(isAdminUser("Dispatcher,Admin")).toBe(true);
|
||||
expect(isAdminUser(" admin ")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-admin roles", () => {
|
||||
expect(isAdminUser("Dispatcher")).toBe(false);
|
||||
expect(isAdminUser(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# PR4 — Pending uplift close gate (+ SH-114 closability union)
|
||||
|
||||
## Summary
|
||||
|
||||
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
|
||||
|
||||
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
|
||||
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
|
||||
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
|
||||
- Completed tooltip uses design copy when the **only** gap is pending uplift.
|
||||
- Maps optional `completedDate` on board rows for the Completed Date gate.
|
||||
|
||||
## Jira
|
||||
|
||||
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
|
||||
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
|
||||
|
||||
## Comp Doc Generate
|
||||
|
||||
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Full uplift CRUD (PR3)
|
||||
- Advanced filter facets for uplift
|
||||
- Scheduled-requires-date gate (design P1)
|
||||
- COL_HELP tooltips (P1)
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
|
||||
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
|
||||
- [ ] Row fully closable — Completed selectable.
|
||||
- [ ] `canGenerateCompDoc` blocked when gaps exist.
|
||||
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
|
||||
|
||||
## Base
|
||||
|
||||
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.
|
||||
Loading…
Add table
Reference in a new issue