Merge branch 'dev' into feature/wo-multi-poc-contacts

This commit is contained in:
Arthur Bassi 2026-08-19 09:42:54 -03:00 • committed by GitHub
commit 5718ca2b92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1081 additions and 221 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,7 @@ export default function WorkOrdersListPage() {
currentUserId={user?.id}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
disabled={filters.advApplied != null}
/>
{filters.advApplied != null && (

View file

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

View file

@ -272,6 +272,14 @@ export interface WorkOrderBoardSearchQueryParams {
pmTypes?: string[];
vendorIds?: number[];
docStatuses?: number[];
severities?: number[];
rescheduled?: boolean;
carriedOver?: boolean;
addOn?: boolean;
flagColors?: string[];
internalOnly?: boolean;
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -68,7 +68,9 @@ describe("toBoardSearchQueryParams", () => {
});
expect(params.page).toBe(0);
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
expect(params.datePreset).toBe("ThisWeek");
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe("2000-01-01");
expect(params.dateTo).toBe("2099-12-31");
});
it("maps date presets to PascalCase API values", () => {
@ -99,8 +101,9 @@ describe("toBoardSearchQueryParams", () => {
customTo: "2026-06-30",
},
});
expect(thisWeek.dateFrom).toBeUndefined();
expect(thisWeek.dateTo).toBeUndefined();
expect(thisWeek.datePreset).toBe("Custom");
expect(thisWeek.dateFrom).toBe("2000-01-01");
expect(thisWeek.dateTo).toBe("2099-12-31");
});
it("maps Overdue-only advanced filter to overdue=true without types", () => {
@ -150,6 +153,50 @@ describe("toBoardSearchQueryParams", () => {
expect(params.pmTypes).toBeUndefined();
expect(params.vendorIds).toBeUndefined();
expect(params.docStatuses).toBeUndefined();
expect(params.severities).toBeUndefined();
expect(params.rescheduled).toBeUndefined();
expect(params.carriedOver).toBeUndefined();
expect(params.addOn).toBeUndefined();
expect(params.flagColors).toBeUndefined();
expect(params.internalOnly).toBeUndefined();
expect(params.hasUplift).toBeUndefined();
expect(params.upliftStatuses).toBeUndefined();
});
it("maps severity and uplift facets onto board search params", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
severities: new Set([1, 3, 5]),
rescheduled: true,
carriedOver: true,
addOn: true,
flagColors: new Set(["#EF4444", "#3B82F6"]),
internalOnly: true,
hasUplift: true,
upliftStatuses: new Set(["pending", "approved"]),
},
});
expect(params.severities).toEqual([1, 3, 5]);
expect(params.rescheduled).toBe(true);
expect(params.carriedOver).toBe(true);
expect(params.addOn).toBe(true);
expect(params.flagColors).toEqual(["#EF4444", "#3B82F6"]);
expect(params.internalOnly).toBe(true);
expect(params.hasUplift).toBe(true);
expect(params.upliftStatuses).toEqual(["pending", "approved"]);
});
it("omits upliftStatuses when hasUplift is false", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
hasUplift: false,
upliftStatuses: new Set(["pending"]),
},
});
expect(params.hasUplift).toBeUndefined();
expect(params.upliftStatuses).toBeUndefined();
});
it("caps pageSize at BOARD_SEARCH_MAX_PAGE_SIZE", () => {