mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 19:22:06 +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) |
|
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
|
||||||
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
|
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
|
||||||
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
|
| `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`
|
### `datePreset`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ export const API_PATHS = {
|
||||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||||
`workorders/${id}/media/${mediaId}`,
|
`workorders/${id}/media/${mediaId}`,
|
||||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
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",
|
lookupsDispatchers: "workorders/lookups/dispatchers",
|
||||||
},
|
},
|
||||||
pmSchedule: {
|
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 type { Dispatch, SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileText,
|
FileText,
|
||||||
Flame,
|
Flame,
|
||||||
|
|
@ -10,24 +10,24 @@ import {
|
||||||
UserX,
|
UserX,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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 { AdvMultiSelect } from "./adv-multi-select";
|
||||||
|
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
|
||||||
import { AdvSection } from "./adv-section";
|
import { AdvSection } from "./adv-section";
|
||||||
|
import { AdvSeverityList } from "./adv-severity-list";
|
||||||
import { AdvTypeGrid } from "./adv-type-grid";
|
import { AdvTypeGrid } from "./adv-type-grid";
|
||||||
import { DateField } from "@/components/ui/date-input";
|
|
||||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
ASSIGNEE_FILTER_UNASSIGNED,
|
ASSIGNEE_FILTER_UNASSIGNED,
|
||||||
COMPLETION_DOC_OPTIONS,
|
COMPLETION_DOC_OPTIONS,
|
||||||
DATE_RANGE_OPTIONS,
|
|
||||||
DOC_FILTER_LABELS,
|
DOC_FILTER_LABELS,
|
||||||
type AdvancedFilterSetKey,
|
type AdvancedFilterSetKey,
|
||||||
type WorkOrderAdvancedFilters,
|
type WorkOrderAdvancedFilters,
|
||||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
|
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
|
|
||||||
|
|
||||||
type AdvancedSearchFilterSectionsProps = {
|
type AdvancedSearchFilterSectionsProps = {
|
||||||
draft: WorkOrderAdvancedFilters;
|
draft: WorkOrderAdvancedFilters;
|
||||||
|
|
@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
||||||
<AdvSection
|
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
|
||||||
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>
|
|
||||||
|
|
||||||
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
|
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
|
|
@ -113,6 +84,10 @@ export function AdvancedSearchFilterSections({
|
||||||
/>
|
/>
|
||||||
</AdvSection>
|
</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}>
|
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
options={[
|
options={[
|
||||||
|
|
@ -149,6 +124,13 @@ export function AdvancedSearchFilterSections({
|
||||||
</div>
|
</div>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvParityFilterSections
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
toggleIn={toggleIn}
|
||||||
|
setMany={setMany}
|
||||||
|
/>
|
||||||
|
|
||||||
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
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[];
|
users: WorkOrderUser[];
|
||||||
usersLoading?: boolean;
|
usersLoading?: boolean;
|
||||||
currentUserId?: string | number;
|
currentUserId?: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function userInitials(name: string): string {
|
function userInitials(name: string): string {
|
||||||
|
|
@ -40,6 +41,7 @@ export function DispatcherFilter({
|
||||||
users,
|
users,
|
||||||
usersLoading = false,
|
usersLoading = false,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
disabled = false,
|
||||||
}: DispatcherFilterProps) {
|
}: DispatcherFilterProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ export function DispatcherFilter({
|
||||||
<MenuAnchorButton
|
<MenuAnchorButton
|
||||||
onAnchor={setAnchor}
|
onAnchor={setAnchor}
|
||||||
open={open}
|
open={open}
|
||||||
|
disabled={disabled}
|
||||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
||||||
style={
|
style={
|
||||||
!allSelected
|
!allSelected
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ type WeekNavProps = {
|
||||||
weekMonday: string;
|
weekMonday: string;
|
||||||
|
|
||||||
onChange: (mondayIso: string) => void;
|
onChange: (mondayIso: string) => void;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBLE_COUNT = 3;
|
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(
|
const weeks = useMemo(
|
||||||
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
||||||
|
|
||||||
|
|
@ -91,7 +93,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<NavArrowButton
|
<NavArrowButton
|
||||||
disabled={!canGoBack}
|
disabled={disabled || !canGoBack}
|
||||||
onClick={() => setOffset((current) => current - 1)}
|
onClick={() => setOffset((current) => current - 1)}
|
||||||
aria-label="Previous weeks"
|
aria-label="Previous weeks"
|
||||||
>
|
>
|
||||||
|
|
@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||||
<NavPillButton
|
<NavPillButton
|
||||||
key={week.mondayIso}
|
key={week.mondayIso}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
disabled={isDisabled}
|
disabled={disabled || isDisabled}
|
||||||
onClick={() => onChange(week.mondayIso)}
|
onClick={() => onChange(week.mondayIso)}
|
||||||
>
|
>
|
||||||
{week.label}
|
{week.label}
|
||||||
|
|
@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<NavArrowButton
|
<NavArrowButton
|
||||||
disabled={!canGoForward}
|
disabled={disabled || !canGoForward}
|
||||||
onClick={() => setOffset((current) => current + 1)}
|
onClick={() => setOffset((current) => current + 1)}
|
||||||
aria-label="Next weeks"
|
aria-label="Next weeks"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,22 @@ type WoTypeSegmentProps = {
|
||||||
value: WorkOrderTypeFilter;
|
value: WorkOrderTypeFilter;
|
||||||
|
|
||||||
onChange: (value: WorkOrderTypeFilter) => void;
|
onChange: (value: WorkOrderTypeFilter) => void;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
|
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
|
||||||
|
|
||||||
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
|
export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) {
|
||||||
return (
|
return (
|
||||||
<SegmentGroup>
|
<SegmentGroup>
|
||||||
{OPTIONS.map((option) => (
|
{OPTIONS.map((option) => (
|
||||||
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
|
<SegmentButton
|
||||||
|
key={option}
|
||||||
|
active={value === option}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
>
|
||||||
{option}
|
{option}
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ type WorkOrdersFilterBarProps = {
|
||||||
currentUserId?: string | number;
|
currentUserId?: string | number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
scopeTotalCount: number;
|
scopeTotalCount: number;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrdersFilterBar({
|
export function WorkOrdersFilterBar({
|
||||||
|
|
@ -30,20 +31,25 @@ export function WorkOrdersFilterBar({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
scopeTotalCount,
|
scopeTotalCount,
|
||||||
|
disabled = false,
|
||||||
}: WorkOrdersFilterBarProps) {
|
}: WorkOrdersFilterBarProps) {
|
||||||
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
|
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
|
||||||
|
|
||||||
return (
|
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
|
<DispatcherFilter
|
||||||
selectedIds={dispatcherIds}
|
selectedIds={dispatcherIds}
|
||||||
onChange={onDispatcherChange}
|
onChange={onDispatcherChange}
|
||||||
users={users}
|
users={users}
|
||||||
usersLoading={usersLoading}
|
usersLoading={usersLoading}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
|
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-52 overflow-y-auto">
|
<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) => (
|
{filtered.map((d) => (
|
||||||
<button
|
<button
|
||||||
key={String(d.id)}
|
key={String(d.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,126 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AlertTriangle, Check } from "lucide-react";
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
type WizardWOStatus,
|
type WizardWOStatus,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
||||||
type StatusCellProps = {
|
type StatusCellProps = {
|
||||||
wo: WorkOrderTableRow;
|
wo: WorkOrderTableRow;
|
||||||
|
|
||||||
onChangeStatus: (status: WizardWOStatus) => void;
|
onChangeStatus: (status: WizardWOStatus) => void;
|
||||||
|
|
||||||
onOpenSlideOver: () => void;
|
onOpenSlideOver: () => void;
|
||||||
|
|
||||||
onPastDueAttempt?: () => 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({
|
export function StatusCell({
|
||||||
wo,
|
wo,
|
||||||
|
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
|
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
}: StatusCellProps) {
|
}: StatusCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const closabilityGaps = rowClosabilityGaps(wo);
|
||||||
|
|
||||||
if (wo.isPastDue) {
|
if (wo.isPastDue) {
|
||||||
return (
|
return (
|
||||||
<>
|
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +130,6 @@ export function StatusCell({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
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>
|
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -134,24 +150,38 @@ export function StatusCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||||
>
|
>
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => {
|
||||||
<button
|
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||||
key={status}
|
const missingTitle = isCompletedBlocked
|
||||||
type="button"
|
? formatClosabilityMissingTitle(closabilityGaps)
|
||||||
onClick={() => {
|
: undefined;
|
||||||
onChangeStatus(status);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
return (
|
||||||
}}
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
key={status}
|
||||||
>
|
type="button"
|
||||||
<WoTableStatusLabel status={status} />
|
disabled={isCompletedBlocked}
|
||||||
|
title={missingTitle}
|
||||||
{wo.status === status && (
|
onClick={() => {
|
||||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
if (isCompletedBlocked) {
|
||||||
)}
|
return;
|
||||||
</button>
|
}
|
||||||
))}
|
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>
|
</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;
|
onWeekChange: (mondayIso: string) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
onScheduleHighlight: (id: string | number | null) => void;
|
onScheduleHighlight: (id: string | number | null) => void;
|
||||||
|
|
@ -28,6 +29,7 @@ export function useWoTableRowHandlers({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight,
|
onScheduleHighlight,
|
||||||
|
|
@ -62,6 +64,12 @@ export function useWoTableRowHandlers({
|
||||||
onOpenCompDoc(row);
|
onOpenCompDoc(row);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
onOpenUplifts: onOpenUplifts
|
||||||
|
? (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenUplifts(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => {
|
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||||
tableData.setSelectedId(row.id);
|
tableData.setSelectedId(row.id);
|
||||||
onOpenMedia(row);
|
onOpenMedia(row);
|
||||||
|
|
@ -136,6 +144,7 @@ export function useWoTableRowHandlers({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight,
|
onScheduleHighlight,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { Fragment, type ReactNode } from "react";
|
import { Fragment, type ReactNode } from "react";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import { Text } from "@/components/ui/text";
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
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 type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
|
||||||
type WoTableBodyProps = {
|
type WoTableBodyProps = {
|
||||||
|
|
@ -15,11 +20,65 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unassignedRows = tableData.unassigned ?? [];
|
||||||
const showUnscheduled =
|
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 (
|
return (
|
||||||
<tbody>
|
<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 && (
|
{showUnscheduled && (
|
||||||
<>
|
<>
|
||||||
<WoTableSectionHeader
|
<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: "appt", label: "APPT TIME", w: 128 },
|
||||||
{ key: "status", label: "STATUS", w: 148 },
|
{ key: "status", label: "STATUS", w: 148 },
|
||||||
{ key: "doc", label: "COMP DOC", w: 84 },
|
{ key: "doc", label: "COMP DOC", w: 84 },
|
||||||
|
{ key: "uplift", label: "UPLIFT", w: 108 },
|
||||||
{ key: "completed", label: "COMPLETED DATE", w: 130 },
|
{ key: "completed", label: "COMPLETED DATE", w: 130 },
|
||||||
{ key: "actions", label: "", w: 100 },
|
{ key: "actions", label: "", w: 100 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const WO_TABLE_MIN_WIDTH = 1634;
|
export const WO_TABLE_MIN_WIDTH = 1742;
|
||||||
export const WO_TABLE_ROW_HEIGHT = 52;
|
export const WO_TABLE_ROW_HEIGHT = 52;
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ export function WoTableInlineRowCells({
|
||||||
|
|
||||||
<InlineMutedDashCell />
|
<InlineMutedDashCell />
|
||||||
<InlineMutedDashCell />
|
<InlineMutedDashCell />
|
||||||
|
<InlineMutedDashCell />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import type {
|
||||||
WorkOrderTableDraft,
|
WorkOrderTableDraft,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} 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 {
|
import {
|
||||||
draftHasScheduleDate,
|
draftHasScheduleDate,
|
||||||
getInlineCreateMissing,
|
getInlineCreateMissing,
|
||||||
|
|
@ -66,7 +69,7 @@ export function WoTableInlineRow({
|
||||||
onCancel,
|
onCancel,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
|
||||||
const missing = getInlineCreateMissing(draft);
|
const missing = getInlineCreateMissing(draft);
|
||||||
const canSave = !saving && missing.length === 0;
|
const canSave = !saving && missing.length === 0;
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { StatusCell } from "./cells/status-cell";
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { UpliftCell } from "./cells/uplift-cell";
|
||||||
import { VendorCell } from "./cells/vendor-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";
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
type WoTableRowServiceCellsProps = {
|
type WoTableRowServiceCellsProps = {
|
||||||
|
|
@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
|
||||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
|
||||||
onView: (row: WorkOrderTableRow) => void;
|
onView: (row: WorkOrderTableRow) => void;
|
||||||
onEdit: (row: WorkOrderTableRow) => void;
|
onEdit: (row: WorkOrderTableRow) => void;
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||||
onScheduleDate: (id: string | number, date: string) => 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 { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
|
import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header";
|
||||||
|
|
||||||
type WoTableSectionHeaderProps = {
|
type WoTableSectionHeaderProps = {
|
||||||
variant: "unscheduled" | "day";
|
variant: "unassigned" | "unscheduled" | "day";
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
isToday?: boolean;
|
isToday?: boolean;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
onAddWo: () => void;
|
onAddWo?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoTableSectionHeader({
|
export function WoTableSectionHeader({
|
||||||
|
|
@ -20,58 +21,30 @@ export function WoTableSectionHeader({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onAddWo,
|
onAddWo,
|
||||||
}: WoTableSectionHeaderProps) {
|
}: WoTableSectionHeaderProps) {
|
||||||
const isUnscheduled = variant === "unscheduled";
|
if (variant === "unassigned") {
|
||||||
|
|
||||||
if (isUnscheduled) {
|
|
||||||
return (
|
return (
|
||||||
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
|
<WoTableCollapsibleSectionHeader
|
||||||
<td
|
tone="unassigned"
|
||||||
colSpan={WO_TABLE_COLS.length}
|
icon={Users}
|
||||||
onClick={onToggleCollapse}
|
label={label}
|
||||||
style={{
|
count={count}
|
||||||
height: 40,
|
collapsed={collapsed}
|
||||||
padding: "0 14px",
|
onToggleCollapse={() => onToggleCollapse?.()}
|
||||||
cursor: "pointer",
|
/>
|
||||||
boxShadow: "inset 3px 0 0 var(--warning)",
|
);
|
||||||
borderTop: "1px solid #FDE68A",
|
}
|
||||||
borderBottom: "1px solid #FDE68A",
|
|
||||||
}}
|
if (variant === "unscheduled") {
|
||||||
>
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<WoTableCollapsibleSectionHeader
|
||||||
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
|
tone="unscheduled"
|
||||||
<span
|
icon={AlertTriangle}
|
||||||
className="uppercase"
|
label={label}
|
||||||
style={{
|
count={count}
|
||||||
fontFamily: "var(--font-mono)",
|
collapsed={collapsed}
|
||||||
fontSize: 12,
|
onToggleCollapse={() => onToggleCollapse?.()}
|
||||||
fontWeight: 700,
|
onAddWo={onAddWo}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,18 +98,17 @@ export function WoTableSectionHeader({
|
||||||
Today
|
Today
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{Boolean(onAddWo) && (
|
||||||
type="button"
|
<button
|
||||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
type="button"
|
||||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||||
onClick={(e) => {
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||||
e.stopPropagation();
|
onClick={onAddWo}
|
||||||
onAddWo();
|
>
|
||||||
}}
|
<Plus size={13} />
|
||||||
>
|
Add WO
|
||||||
<Plus size={13} />
|
</button>
|
||||||
Add WO
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
onOpenAdvancedFilters?: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
|
|
@ -49,6 +50,7 @@ export function WoTable({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
|
|
@ -71,6 +73,7 @@ export function WoTable({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight: setDueDateHighlightId,
|
onScheduleHighlight: setDueDateHighlightId,
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,22 @@
|
||||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
type WorkOrdersListOverlaysProps = {
|
type WorkOrdersListOverlaysProps = {
|
||||||
wizardOpen: boolean;
|
activeUpliftsRow: WorkOrderTableRow | null;
|
||||||
onWizardOpenChange: (open: boolean) => void;
|
onCloseUplifts: () => 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrdersListOverlays({
|
export function WorkOrdersListOverlays({
|
||||||
wizardOpen,
|
activeUpliftsRow,
|
||||||
onWizardOpenChange,
|
onCloseUplifts,
|
||||||
users,
|
|
||||||
sites,
|
|
||||||
pmTypes,
|
|
||||||
vendorTechs,
|
|
||||||
advOpen,
|
|
||||||
onAdvOpenChange,
|
|
||||||
advInitial,
|
|
||||||
onApplyAdvanced,
|
|
||||||
onClearAdvanced,
|
|
||||||
activeDocRow,
|
|
||||||
onDocOpenChange,
|
|
||||||
completionDocs,
|
|
||||||
onSaveCompDoc,
|
|
||||||
onDateCompletedChange,
|
|
||||||
activeMediaRow,
|
|
||||||
onMediaOpenChange,
|
|
||||||
}: WorkOrdersListOverlaysProps) {
|
}: WorkOrdersListOverlaysProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WorkOrdersAdvancedSearchSheet
|
{activeUpliftsRow != null && (
|
||||||
open={advOpen}
|
<WorkOrderUpliftsDialog
|
||||||
onOpenChange={onAdvOpenChange}
|
row={activeUpliftsRow}
|
||||||
initial={advInitial}
|
open={Boolean(activeUpliftsRow)}
|
||||||
onApply={onApplyAdvanced}
|
onClose={onCloseUplifts}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 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 { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
import {
|
import {
|
||||||
useWorkOrdersBoard,
|
useWorkOrdersBoard,
|
||||||
useWorkOrdersBoardSearch,
|
useWorkOrdersBoardSearch,
|
||||||
|
useWorkOrdersUnassignedQueue,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import {
|
import {
|
||||||
useWorkOrderLocations,
|
useWorkOrderLocations,
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
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";
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
export type WorkOrdersBoardSource = {
|
export type WorkOrdersBoardSource = {
|
||||||
advActive: boolean;
|
advActive: boolean;
|
||||||
data: WorkOrderBoardListResult | undefined;
|
data: WorkOrderBoardListResult | undefined;
|
||||||
|
unassignedQueue: WorkOrderTableRow[];
|
||||||
|
unassignedError: Error | null;
|
||||||
|
unassignedLoading: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
|
@ -67,13 +74,35 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
||||||
error: searchError,
|
error: searchError,
|
||||||
refetch: refetchSearch,
|
refetch: refetchSearch,
|
||||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
} = 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 {
|
return {
|
||||||
advActive,
|
advActive,
|
||||||
data: advActive ? searchData : boardData,
|
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,
|
isLoading: advActive ? searchLoading : boardLoading,
|
||||||
isFetching: advActive ? searchFetching : boardFetching,
|
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
|
||||||
error: advActive ? searchError : boardError,
|
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 [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [mediaRow, setMediaRow] = 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 [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const filters = useWorkOrdersListFilters();
|
const filters = useWorkOrdersListFilters();
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
|
@ -62,6 +63,11 @@ export function useWorkOrdersListPage() {
|
||||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
}, [mediaRow, tableData.rows]);
|
}, [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) => {
|
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||||
setSlideOverRow(row);
|
setSlideOverRow(row);
|
||||||
setSlideOverEdit(edit);
|
setSlideOverEdit(edit);
|
||||||
|
|
@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
|
||||||
setDocRow,
|
setDocRow,
|
||||||
mediaRow,
|
mediaRow,
|
||||||
setMediaRow,
|
setMediaRow,
|
||||||
|
setUpliftsRow,
|
||||||
completionDocs,
|
completionDocs,
|
||||||
filters,
|
filters,
|
||||||
tableData,
|
tableData,
|
||||||
|
|
@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
|
||||||
activeSlideOverRow,
|
activeSlideOverRow,
|
||||||
activeDocRow,
|
activeDocRow,
|
||||||
activeMediaRow,
|
activeMediaRow,
|
||||||
|
activeUpliftsRow,
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
handleCloseSlideOver,
|
handleCloseSlideOver,
|
||||||
handleConfirmCancel,
|
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 { 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 { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
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 { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
|
composeBoardSections,
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
|
mergeWeeklyAndUnassignedQueue,
|
||||||
|
rowIdSet,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} 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>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const { advActive, data, isLoading, isFetching, error, refetch } =
|
const {
|
||||||
useWorkOrdersBoardSource(filters);
|
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({
|
const local = useWorkOrdersTableLocalState({
|
||||||
items: data?.items ?? [],
|
items,
|
||||||
weekMonday: filters.weekMonday,
|
weekMonday: filters.weekMonday,
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
|
@ -38,10 +61,18 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
return displayRows;
|
return displayRows;
|
||||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
const { scheduled, unscheduled } = useMemo(
|
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
||||||
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
if (advActive) {
|
||||||
[advActive, advResults, filtered],
|
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 dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||||
|
|
@ -49,7 +80,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const visibleRows = advActive ? advResults : filtered;
|
const visibleRows = advActive ? advResults : filtered;
|
||||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
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 displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||||
|
|
||||||
const todayKey =
|
const todayKey =
|
||||||
|
|
@ -70,6 +106,9 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
refetch,
|
refetch,
|
||||||
advActive,
|
advActive,
|
||||||
advResults,
|
advResults,
|
||||||
|
unassigned,
|
||||||
|
unassignedError,
|
||||||
|
unassignedLoading,
|
||||||
unscheduled,
|
unscheduled,
|
||||||
displayGroups,
|
displayGroups,
|
||||||
searchActive,
|
searchActive,
|
||||||
|
|
@ -79,6 +118,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
draftDayKey: local.draftDayKey,
|
draftDayKey: local.draftDayKey,
|
||||||
draft: local.draft,
|
draft: local.draft,
|
||||||
setDraft: local.setDraft,
|
setDraft: local.setDraft,
|
||||||
|
unassignedOpen: local.unassignedOpen,
|
||||||
|
setUnassignedOpen: local.setUnassignedOpen,
|
||||||
unschedOpen: local.unschedOpen,
|
unschedOpen: local.unschedOpen,
|
||||||
setUnschedOpen: local.setUnschedOpen,
|
setUnschedOpen: local.setUnschedOpen,
|
||||||
selectedId: local.selectedId,
|
selectedId: local.selectedId,
|
||||||
|
|
@ -99,7 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
totalCount: advActive
|
totalCount: advActive
|
||||||
? (data?.totalCount ?? advResults.length)
|
? (data?.totalCount ?? advResults.length)
|
||||||
: (data?.returnedCount ?? visibleRows.length),
|
: (data?.returnedCount ?? weeklyItems.length),
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({
|
||||||
setDraft: ui.setDraft,
|
setDraft: ui.setDraft,
|
||||||
startDayDraft: ui.startDayDraft,
|
startDayDraft: ui.startDayDraft,
|
||||||
cancelDraft: ui.cancelDraft,
|
cancelDraft: ui.cancelDraft,
|
||||||
|
unassignedOpen: ui.unassignedOpen,
|
||||||
|
setUnassignedOpen: ui.setUnassignedOpen,
|
||||||
unschedOpen: ui.unschedOpen,
|
unschedOpen: ui.unschedOpen,
|
||||||
setUnschedOpen: ui.setUnschedOpen,
|
setUnschedOpen: ui.setUnschedOpen,
|
||||||
selectedId: ui.selectedId,
|
selectedId: ui.selectedId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
EMPTY_TABLE_DRAFT,
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
UNSCHEDULED_DAY_KEY,
|
UNSCHEDULED_DAY_KEY,
|
||||||
type WorkOrderTableDraft,
|
type WorkOrderTableDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} 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 {
|
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
||||||
if (key === UNSCHEDULED_DAY_KEY) {
|
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||||
|
|
@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?:
|
||||||
return "";
|
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) {
|
export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||||
|
const [unassignedOpen, setUnassignedOpen] = useState(true);
|
||||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||||
const [flashId, setFlashId] = 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) => {
|
(key: string, scheduledIso?: string) => {
|
||||||
setDraftDayKey(key);
|
setDraftDayKey(key);
|
||||||
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
// 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 });
|
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +87,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
setDraft,
|
setDraft,
|
||||||
startDayDraft,
|
startDayDraft,
|
||||||
cancelDraft,
|
cancelDraft,
|
||||||
|
unassignedOpen,
|
||||||
|
setUnassignedOpen,
|
||||||
unschedOpen,
|
unschedOpen,
|
||||||
setUnschedOpen,
|
setUnschedOpen,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
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 { 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 { 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 { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
|
||||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
||||||
export default function WorkOrdersListPage() {
|
export default function WorkOrdersListPage() {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const page = useWorkOrdersListPage();
|
const page = useWorkOrdersListPage();
|
||||||
const { filters, tableData, activeDocRow } = page;
|
const { filters, tableData } = page;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
|
|
@ -49,6 +44,7 @@ export default function WorkOrdersListPage() {
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
filteredCount={tableData.totalCount}
|
filteredCount={tableData.totalCount}
|
||||||
scopeTotalCount={tableData.scopeTotalCount}
|
scopeTotalCount={tableData.scopeTotalCount}
|
||||||
|
disabled={filters.advApplied != null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filters.advApplied != null && (
|
{filters.advApplied != null && (
|
||||||
|
|
@ -86,79 +82,13 @@ export default function WorkOrdersListPage() {
|
||||||
onOpenSlideOver={page.handleOpenSlideOver}
|
onOpenSlideOver={page.handleOpenSlideOver}
|
||||||
onOpenCompDoc={page.setDocRow}
|
onOpenCompDoc={page.setDocRow}
|
||||||
onOpenMedia={page.setMediaRow}
|
onOpenMedia={page.setMediaRow}
|
||||||
|
onOpenUplifts={page.setUpliftsRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onPatch={page.patchField}
|
onPatch={page.patchField}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrdersListWizard
|
<WorkOrdersListPagePanels page={page} />
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</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 { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||||
import {
|
import {
|
||||||
DOC_STATUS_API,
|
DOC_STATUS_API,
|
||||||
LIFECYCLE_STATUS_API_TO_FE,
|
LIFECYCLE_STATUS_API_TO_FE,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
CompletionDocStatus,
|
CompletionDocStatus,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
|
WorkOrderUpliftSummary,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
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;
|
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 {
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const scheduledOn = toDateInputValue(
|
const scheduledOn = toDateInputValue(
|
||||||
|
|
@ -269,6 +320,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
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 = {
|
const row: WorkOrderTableRow = {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
|
@ -279,7 +333,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
dispatcherName,
|
dispatcherName,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
completedDate: toDateInputValue(
|
||||||
|
readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
|
||||||
|
),
|
||||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||||
dayGroup,
|
dayGroup,
|
||||||
dayLabel,
|
dayLabel,
|
||||||
|
|
@ -316,6 +372,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||||
flagColor: readFlagColor(item),
|
flagColor: readFlagColor(item),
|
||||||
|
upliftSummary,
|
||||||
|
hasPendingUplift,
|
||||||
};
|
};
|
||||||
|
|
||||||
return row;
|
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 { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
|
||||||
|
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
import type { UpliftFilterStatus } from "./work-order-uplift-filter";
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
WO_TYPE_META,
|
WO_TYPE_META,
|
||||||
|
|
@ -26,11 +28,19 @@ export interface WorkOrderAdvancedFilters {
|
||||||
customTo: string;
|
customTo: string;
|
||||||
sites: Set<string>;
|
sites: Set<string>;
|
||||||
types: Set<WOType>;
|
types: Set<WOType>;
|
||||||
|
severities: Set<WorkOrderSeverity>;
|
||||||
dispatchers: Set<string>;
|
dispatchers: Set<string>;
|
||||||
statuses: Set<WizardWOStatus>;
|
statuses: Set<WizardWOStatus>;
|
||||||
|
rescheduled: boolean;
|
||||||
|
carriedOver: boolean;
|
||||||
|
addOn: boolean;
|
||||||
pmTypes: Set<string>;
|
pmTypes: Set<string>;
|
||||||
vendorTechs: Set<string>;
|
vendorTechs: Set<string>;
|
||||||
docs: Set<CompletionDocFilter>;
|
docs: Set<CompletionDocFilter>;
|
||||||
|
flagColors: Set<string>;
|
||||||
|
internalOnly: boolean;
|
||||||
|
hasUplift: boolean;
|
||||||
|
upliftStatuses: Set<UpliftFilterStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
|
|
@ -39,11 +49,19 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
customTo: "",
|
customTo: "",
|
||||||
sites: new Set(),
|
sites: new Set(),
|
||||||
types: new Set(),
|
types: new Set(),
|
||||||
|
severities: new Set(),
|
||||||
dispatchers: new Set(),
|
dispatchers: new Set(),
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
|
rescheduled: false,
|
||||||
|
carriedOver: false,
|
||||||
|
addOn: false,
|
||||||
pmTypes: new Set(),
|
pmTypes: new Set(),
|
||||||
vendorTechs: new Set(),
|
vendorTechs: new Set(),
|
||||||
docs: new Set(),
|
docs: new Set(),
|
||||||
|
flagColors: new Set(),
|
||||||
|
internalOnly: false,
|
||||||
|
hasUplift: false,
|
||||||
|
upliftStatuses: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
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 { ALL_WIZARD_STATUSES, WO_TYPES };
|
||||||
|
|
||||||
export type AdvancedFilterSetKey =
|
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];
|
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. */
|
/** 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 | ""> = {
|
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||||
|
|
@ -179,6 +191,11 @@ export interface WorkOrderBoardRowApi {
|
||||||
primaryDispatchId?: number | null;
|
primaryDispatchId?: number | null;
|
||||||
rowVersion?: string | null;
|
rowVersion?: string | null;
|
||||||
dispatchRowVersion?: 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 {
|
export interface WorkOrderBoardCountsApi {
|
||||||
|
|
@ -251,6 +268,14 @@ export interface WorkOrderBoardSearchQueryParams {
|
||||||
pmTypes?: string[];
|
pmTypes?: string[];
|
||||||
vendorIds?: number[];
|
vendorIds?: number[];
|
||||||
docStatuses?: number[];
|
docStatuses?: number[];
|
||||||
|
severities?: number[];
|
||||||
|
rescheduled?: boolean;
|
||||||
|
carriedOver?: boolean;
|
||||||
|
addOn?: boolean;
|
||||||
|
flagColors?: string[];
|
||||||
|
internalOnly?: boolean;
|
||||||
|
hasUplift?: boolean;
|
||||||
|
upliftStatuses?: string[];
|
||||||
myWorkOrders?: boolean;
|
myWorkOrders?: boolean;
|
||||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||||
sortDir?: "asc" | "desc" | string;
|
sortDir?: "asc" | "desc" | string;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
import type { UpliftSummary } from "./work-order-uplift";
|
||||||
|
|
||||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export type WorkOrderUpliftSummary = {
|
||||||
|
hasUplift: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
primaryStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface WorkOrderTableRow {
|
export interface WorkOrderTableRow {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
|
|
@ -15,7 +22,7 @@ export interface WorkOrderTableRow {
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
dispatcherName: string;
|
dispatcherName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
|
/** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
|
||||||
completedDate: string;
|
completedDate: string;
|
||||||
targetWeek: string;
|
targetWeek: string;
|
||||||
dayGroup: string;
|
dayGroup: string;
|
||||||
|
|
@ -59,6 +66,8 @@ export interface WorkOrderTableRow {
|
||||||
dispatchRowVersion: string;
|
dispatchRowVersion: string;
|
||||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||||
flagColor: string | null;
|
flagColor: string | null;
|
||||||
|
upliftSummary?: UpliftSummary | null;
|
||||||
|
hasPendingUplift?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderTableDraft {
|
export interface WorkOrderTableDraft {
|
||||||
|
|
@ -130,3 +139,4 @@ export type WorkOrderDayGroup = {
|
||||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||||
|
|
||||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
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 {
|
export {
|
||||||
EMPTY_TABLE_DRAFT,
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
UNSCHEDULED_DAY_KEY,
|
UNSCHEDULED_DAY_KEY,
|
||||||
WORK_ORDERS_TABLE_PAGE_SIZE,
|
WORK_ORDERS_TABLE_PAGE_SIZE,
|
||||||
} from "./work-order-table-row";
|
} 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 { 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 { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import type {
|
import type {
|
||||||
WorkOrdersListParams,
|
WorkOrdersListParams,
|
||||||
|
|
@ -10,6 +11,7 @@ import type {
|
||||||
WorkOrderBoardQueryParams,
|
WorkOrderBoardQueryParams,
|
||||||
WorkOrderBoardSearchQueryParams,
|
WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} 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";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useWorkOrdersList(
|
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> {
|
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.dispatchers(),
|
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) =>
|
const matchesDoc: AdvancedFilterPredicate = (row, filters) =>
|
||||||
!filters.docs.size || filters.docs.has(row.docStatus as never);
|
!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. */
|
/** One predicate per filter dimension; a row matches when every predicate is true. */
|
||||||
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
matchesDateRange,
|
matchesDateRange,
|
||||||
|
|
@ -54,4 +66,8 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
matchesPmType,
|
matchesPmType,
|
||||||
matchesVendorTech,
|
matchesVendorTech,
|
||||||
matchesDoc,
|
matchesDoc,
|
||||||
|
matchesRescheduled,
|
||||||
|
matchesCarriedOver,
|
||||||
|
matchesInternalOnly,
|
||||||
|
matchesFlagColors,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,60 @@
|
||||||
import {
|
import {
|
||||||
ASSIGNEE_FILTER_UNASSIGNED,
|
|
||||||
DATE_RANGE_OPTIONS,
|
|
||||||
DOC_FILTER_LABELS,
|
|
||||||
EMPTY_ADVANCED_FILTERS,
|
EMPTY_ADVANCED_FILTERS,
|
||||||
type CompletionDocFilter,
|
|
||||||
type DateRangeKey,
|
type DateRangeKey,
|
||||||
type WorkOrderAdvancedFilters,
|
type WorkOrderAdvancedFilters,
|
||||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import {
|
import {
|
||||||
MONTH_NAMES,
|
appendDateChip,
|
||||||
addDaysIso,
|
appendDispatcherChips,
|
||||||
mondayOf,
|
appendFlagAndInternalChips,
|
||||||
pad2,
|
appendIndicatorChips,
|
||||||
todayIso,
|
appendSetChips,
|
||||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
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 {
|
export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||||
key: string;
|
|
||||||
group: string;
|
|
||||||
value?: string;
|
|
||||||
count?: number;
|
|
||||||
accent?: boolean;
|
|
||||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
|
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
|
||||||
return {
|
return {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
...filters,
|
...filters,
|
||||||
sites: new Set(filters.sites),
|
sites: new Set(filters.sites),
|
||||||
types: new Set(filters.types),
|
types: new Set(filters.types),
|
||||||
|
severities: new Set(filters.severities ?? []),
|
||||||
dispatchers: new Set(filters.dispatchers),
|
dispatchers: new Set(filters.dispatchers),
|
||||||
statuses: new Set(filters.statuses),
|
statuses: new Set(filters.statuses),
|
||||||
pmTypes: new Set(filters.pmTypes),
|
pmTypes: new Set(filters.pmTypes),
|
||||||
vendorTechs: new Set(filters.vendorTechs),
|
vendorTechs: new Set(filters.vendorTechs),
|
||||||
docs: new Set(filters.docs),
|
docs: new Set(filters.docs),
|
||||||
|
flagColors: new Set(filters.flagColors ?? []),
|
||||||
|
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
|
||||||
let count = 0;
|
filters.dateRange !== "this-week",
|
||||||
if (filters.dateRange !== "this-week") count += 1;
|
Boolean(filters.sites?.size),
|
||||||
if (filters.sites.size) count += 1;
|
Boolean(filters.types?.size),
|
||||||
if (filters.types.size) count += 1;
|
Boolean(filters.severities?.size),
|
||||||
if (filters.dispatchers.size) count += 1;
|
Boolean(filters.dispatchers?.size),
|
||||||
if (filters.statuses.size) count += 1;
|
Boolean(filters.statuses?.size),
|
||||||
if (filters.pmTypes.size) count += 1;
|
filters.rescheduled,
|
||||||
if (filters.vendorTechs.size) count += 1;
|
filters.carriedOver,
|
||||||
if (filters.docs.size) count += 1;
|
filters.addOn,
|
||||||
return count;
|
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 {
|
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
||||||
const [, m, d] = iso.split("-").map(Number);
|
|
||||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthStart(y: number, m: number): string {
|
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(
|
export function buildAdvancedFilterChips(
|
||||||
filters: WorkOrderAdvancedFilters,
|
filters: WorkOrderAdvancedFilters,
|
||||||
options?: {
|
options?: {
|
||||||
|
|
@ -115,100 +100,19 @@ export function buildAdvancedFilterChips(
|
||||||
},
|
},
|
||||||
): AdvancedFilterChip[] {
|
): AdvancedFilterChip[] {
|
||||||
const chips: AdvancedFilterChip[] = [];
|
const chips: AdvancedFilterChip[] = [];
|
||||||
const siteNameOf = options?.siteNameOf ?? ((v) => v);
|
const ctx = {
|
||||||
const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
|
siteNameOf: options?.siteNameOf ?? ((v: string) => v),
|
||||||
|
dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v),
|
||||||
|
};
|
||||||
|
|
||||||
if (filters.dateRange !== "this-week") {
|
appendDateChip(chips, filters);
|
||||||
const value =
|
appendSetChips(chips, filters, ctx);
|
||||||
filters.dateRange === "custom"
|
appendSeverityChip(chips, filters);
|
||||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
appendDispatcherChips(chips, filters, ctx);
|
||||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
appendStatusPmVendorDocChips(chips, filters);
|
||||||
chips.push({
|
appendIndicatorChips(chips, filters);
|
||||||
key: "date",
|
appendFlagAndInternalChips(chips, filters);
|
||||||
group: "Date",
|
appendUpliftChip(chips, filters);
|
||||||
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() }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
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 type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||||
import {
|
import {
|
||||||
BOARD_DISPATCHER_UNASSIGNED,
|
BOARD_DISPATCHER_UNASSIGNED,
|
||||||
DATE_RANGE_TO_PRESET,
|
DATE_RANGE_TO_PRESET,
|
||||||
DOC_STATUS_API,
|
DOC_STATUS_API,
|
||||||
LIFECYCLE_STATUS_API,
|
LIFECYCLE_STATUS_API,
|
||||||
|
NON_TERMINAL_LIFECYCLE_STATUSES,
|
||||||
type WorkOrderBoardQueryParams,
|
type WorkOrderBoardQueryParams,
|
||||||
type WorkOrderBoardSearchQueryParams,
|
type WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} 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;
|
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 {
|
function toBoardDispatcherId(id: string): string {
|
||||||
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
||||||
return BOARD_DISPATCHER_UNASSIGNED;
|
return BOARD_DISPATCHER_UNASSIGNED;
|
||||||
|
|
@ -77,6 +93,47 @@ function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefin
|
||||||
return list.length > 0 ? list : undefined;
|
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. */
|
/** Build GET /board query from weekly filter bar. */
|
||||||
export function toBoardQueryParams(
|
export function toBoardQueryParams(
|
||||||
bar: WorkOrderBarFilters,
|
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). */
|
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
|
||||||
export function toBoardSearchQueryParams(input: {
|
export function toBoardSearchQueryParams(input: {
|
||||||
filters: WorkOrderAdvancedFilters;
|
filters: WorkOrderAdvancedFilters;
|
||||||
|
|
@ -107,48 +189,35 @@ export function toBoardSearchQueryParams(input: {
|
||||||
vendorIdsByCompanyName?: Map<string, number>;
|
vendorIdsByCompanyName?: Map<string, number>;
|
||||||
}): WorkOrderBoardSearchQueryParams {
|
}): WorkOrderBoardSearchQueryParams {
|
||||||
const { filters } = input;
|
const { filters } = input;
|
||||||
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
|
const date = resolveAdvancedSearchDate(filters);
|
||||||
const pageSize = Math.min(
|
const pageSize = Math.min(
|
||||||
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
|
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
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 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,
|
page: input.page ?? 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
search:
|
search,
|
||||||
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
|
datePreset: date.datePreset,
|
||||||
datePreset,
|
dateFrom: date.dateFrom,
|
||||||
|
dateTo: date.dateTo,
|
||||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||||
types: typeFilters.types,
|
types: typeFilters.types,
|
||||||
overdue: typeFilters.overdue,
|
overdue: typeFilters.overdue,
|
||||||
dispatchers: mapDispatchers(filters.dispatchers),
|
dispatchers: mapDispatchers(filters.dispatchers),
|
||||||
statuses: mapStatuses(filters.statuses),
|
statuses: mapStatuses(filters.statuses),
|
||||||
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||||
vendorIds: vendorIds.length > 0 ? vendorIds : undefined,
|
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
|
||||||
docStatuses: mapDocStatuses(filters.docs),
|
docStatuses: mapDocStatuses(filters.docs),
|
||||||
|
...mapSearchFacetParams(filters),
|
||||||
sortBy: input.sortBy || "scheduledDate",
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
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. */
|
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
|
||||||
|
|
@ -175,3 +244,16 @@ export function buildBoardSearchParamsRecord(
|
||||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||||
return type === "Overdue";
|
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[]): {
|
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
|
||||||
scheduled: WorkOrderTableRow[];
|
scheduled: WorkOrderTableRow[];
|
||||||
unscheduled: 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,
|
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
||||||
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
||||||
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
||||||
|
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
|
||||||
byLocation: (locationId: string | number, search = "") =>
|
byLocation: (locationId: string | number, search = "") =>
|
||||||
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
||||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] 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,
|
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||||
|
uplifts: (workOrderId: string | number) =>
|
||||||
|
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
|
||||||
},
|
},
|
||||||
pmSchedules: {
|
pmSchedules: {
|
||||||
all: ["pmSchedules"] as const,
|
all: ["pmSchedules"] as const,
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,13 @@ export function getPrimaryUserRole(
|
||||||
): string {
|
): string {
|
||||||
return userRoles?.split(",")[0]?.trim() || fallback;
|
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: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
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: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...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: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: WoTableRowHandlers = {
|
const handlers: WoTableRowHandlers = {
|
||||||
|
|
@ -76,6 +77,11 @@ const handlers: WoTableRowHandlers = {
|
||||||
function renderUnscheduledHarness() {
|
function renderUnscheduledHarness() {
|
||||||
const tableData = {
|
const tableData = {
|
||||||
advActive: false,
|
advActive: false,
|
||||||
|
unassigned: [],
|
||||||
|
unassignedError: null,
|
||||||
|
unassignedLoading: false,
|
||||||
|
unassignedOpen: true,
|
||||||
|
setUnassignedOpen: vi.fn(),
|
||||||
unscheduled: [unscheduledRow],
|
unscheduled: [unscheduledRow],
|
||||||
unschedOpen: true,
|
unschedOpen: true,
|
||||||
draftDayKey: null,
|
draftDayKey: null,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
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";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const setAdvOpen = vi.fn();
|
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", () => ({
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||||
useWorkOrdersTableData: () => ({
|
useWorkOrdersTableData: () => ({
|
||||||
rows: [],
|
rows: [],
|
||||||
totalCount: 0,
|
totalCount: advApplied ? 42 : 0,
|
||||||
scopeTotalCount: 0,
|
scopeTotalCount: advApplied ? 42 : 0,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
setSelectedId: vi.fn(),
|
setSelectedId: vi.fn(),
|
||||||
|
|
@ -102,10 +106,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
|
||||||
WoTable: () => null,
|
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", () => ({
|
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
|
||||||
MediaUploadModal: () => null,
|
MediaUploadModal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -158,4 +158,113 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
);
|
);
|
||||||
expect(clearAdvancedFilters).toHaveBeenCalled();
|
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: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
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: "" }).flagColor).toBeNull();
|
||||||
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
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: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
};
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
|
||||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
let serverState: ServerState;
|
let serverState: ServerState;
|
||||||
|
|
|
||||||
|
|
@ -100,4 +100,46 @@ describe("clearAdvancedFilterGroup", () => {
|
||||||
const next = clearAdvancedFilterGroup(withSites("1"), "sites");
|
const next = clearAdvancedFilterGroup(withSites("1"), "sites");
|
||||||
expect(next).toBeNull();
|
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,
|
buildBoardSearchParamsRecord,
|
||||||
toBoardQueryParams,
|
toBoardQueryParams,
|
||||||
toBoardSearchQueryParams,
|
toBoardSearchQueryParams,
|
||||||
|
toUnassignedQueueQueryParams,
|
||||||
|
UNASSIGNED_QUEUE_DATE_FROM,
|
||||||
|
UNASSIGNED_QUEUE_DATE_TO,
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
describe("toBoardQueryParams", () => {
|
describe("toBoardQueryParams", () => {
|
||||||
|
|
@ -65,7 +68,9 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
});
|
});
|
||||||
expect(params.page).toBe(0);
|
expect(params.page).toBe(0);
|
||||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
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", () => {
|
it("maps date presets to PascalCase API values", () => {
|
||||||
|
|
@ -96,8 +101,9 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
customTo: "2026-06-30",
|
customTo: "2026-06-30",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(thisWeek.dateFrom).toBeUndefined();
|
expect(thisWeek.datePreset).toBe("Custom");
|
||||||
expect(thisWeek.dateTo).toBeUndefined();
|
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", () => {
|
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
||||||
|
|
@ -147,6 +153,50 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(params.pmTypes).toBeUndefined();
|
expect(params.pmTypes).toBeUndefined();
|
||||||
expect(params.vendorIds).toBeUndefined();
|
expect(params.vendorIds).toBeUndefined();
|
||||||
expect(params.docStatuses).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", () => {
|
it("caps pageSize at BOARD_SEARCH_MAX_PAGE_SIZE", () => {
|
||||||
|
|
@ -174,3 +224,21 @@ describe("buildBoardSearchParamsRecord", () => {
|
||||||
expect(Object.keys(record)).not.toContain("types[]");
|
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,
|
isSlideOverDraftDirty,
|
||||||
SLIDE_OVER_EDIT_KEYS,
|
SLIDE_OVER_EDIT_KEYS,
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
} 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";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
|
@ -52,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "1",
|
rowVersion: "1",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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 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 { 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 {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
|
composeBoardSections,
|
||||||
computeIsPastDue,
|
computeIsPastDue,
|
||||||
enrichTableRow,
|
enrichTableRow,
|
||||||
|
filterByBarAndSearch,
|
||||||
|
isUnassignedBoardRow,
|
||||||
matchesAdvancedFilters,
|
matchesAdvancedFilters,
|
||||||
|
mergeWeeklyAndUnassignedQueue,
|
||||||
|
partitionBoardRows,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
|
@ -63,6 +70,19 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "AQAAAAAAAAA=",
|
rowVersion: "AQAAAAAAAAA=",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(
|
||||||
|
weekMonday: string,
|
||||||
|
overrides: Partial<WorkOrderBarFilters> = {},
|
||||||
|
): WorkOrderBarFilters {
|
||||||
|
return {
|
||||||
|
weekMonday,
|
||||||
|
dispatcherIds: new Set(),
|
||||||
|
type: "All",
|
||||||
...overrides,
|
...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", () => {
|
describe("splitScheduledUnscheduled", () => {
|
||||||
it("splits rows without scheduledOn into unscheduled", () => {
|
it("splits rows without scheduledOn into unscheduled", () => {
|
||||||
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
|
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