diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..6e7134f3 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -57,6 +57,41 @@ 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`. ### `datePreset` diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx new file mode 100644 index 00000000..06e2104e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx @@ -0,0 +1,50 @@ +import type { Dispatch, SetStateAction } from "react"; +import { CalendarDays } from "lucide-react"; +import { AdvFilterPill } from "./adv-filter-pill"; +import { AdvSection } from "./adv-section"; +import { DateField } from "@/components/ui/date-input"; +import { + DATE_RANGE_OPTIONS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils"; + +type AdvDateRangeSectionProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; +}; + +export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) { + return ( + +
+ {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
+ {draft.dateRange === "custom" && ( +
+ setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
+ )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 9bfed9a1..88ba6ca3 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from "react"; import { - CalendarDays, + AlertTriangle, CheckCircle2, FileText, Flame, @@ -10,24 +10,24 @@ import { UserX, Users, } from "lucide-react"; -import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvDateRangeSection } from "./adv-date-range-section"; import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvParityFilterSections } from "./adv-parity-filter-sections"; import { AdvSection } from "./adv-section"; +import { AdvSeverityList } from "./adv-severity-list"; import { AdvTypeGrid } from "./adv-type-grid"; -import { DateField } from "@/components/ui/date-input"; import { SelectAllAction } from "@/components/ui/select-all-action"; import { ALL_WIZARD_STATUSES, ASSIGNEE_FILTER_UNASSIGNED, COMPLETION_DOC_OPTIONS, - DATE_RANGE_OPTIONS, DOC_FILTER_LABELS, type AdvancedFilterSetKey, type WorkOrderAdvancedFilters, } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; -import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils"; type AdvancedSearchFilterSectionsProps = { draft: WorkOrderAdvancedFilters; @@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({ return (
- -
- {DATE_RANGE_OPTIONS.map((option) => ( - setDraft((prev) => ({ ...prev, dateRange: option.key }))} - /> - ))} -
- {draft.dateRange === "custom" && ( -
- setDraft((prev) => ({ ...prev, customFrom: value }))} - /> - setDraft((prev) => ({ ...prev, customTo: value }))} - /> -
- )} -
+ + + + + + + ({ value: pmType, label: pmType }))} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx new file mode 100644 index 00000000..559004c8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -0,0 +1,146 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react"; +import { Checkbox } from "@mui/material"; +import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill"; +import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvSection } from "./adv-section"; +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; +import { + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + UPLIFT_FILTER_LABELS, + UPLIFT_FILTER_STATUSES, + type UpliftFilterStatus, +} from "@/domain/work-orders/types/work-order-uplift-filter"; + +type AdvParityFilterSectionsProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; + toggleIn: (key: AdvancedFilterSetKey, value: string) => void; + setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void; +}; + +function indicatorCount(draft: WorkOrderAdvancedFilters): number { + return (draft.rescheduled ? 1 : 0) + (draft.carriedOver ? 1 : 0) + (draft.addOn ? 1 : 0); +} + +function FlagColorDot({ value }: { value: string }) { + return ( + + ); +} + +export function AdvParityFilterSections({ + draft, + setDraft, + toggleIn, + setMany, +}: AdvParityFilterSectionsProps) { + const toggleUpliftStatus = (status: UpliftFilterStatus) => { + setDraft((prev) => { + const next = new Set(prev.upliftStatuses); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return { ...prev, upliftStatuses: next }; + }); + }; + + return ( + <> + +
+ setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))} + className="inline-flex items-center gap-1.5" + /> +
+
+ + + ({ value: color.value, label: color.name }))} + selected={draft.flagColors} + onToggle={(value) => toggleIn("flagColors", value)} + onBulkToggle={(values, select) => setMany("flagColors", values, select)} + placeholder="Search flag colour…" + renderLeft={(value) => } + /> + + + + + + + + + {Boolean(draft.hasUplift) && ( +
+ {UPLIFT_FILTER_STATUSES.map((status) => ( + toggleUpliftStatus(status)} + /> + ))} +
+ )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx new file mode 100644 index 00000000..05e6d9c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx @@ -0,0 +1,84 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Check } from "lucide-react"; +import { SelectAllAction } from "@/components/ui/select-all-action"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; + +type AdvSeverityListProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; +}; + +export function AdvSeverityList({ draft, setDraft }: AdvSeverityListProps) { + const toggleLevel = (level: WorkOrderSeverity) => { + setDraft((prev) => { + const next = new Set(prev.severities); + if (next.has(level)) { + next.delete(level); + } else { + next.add(level); + } + return { ...prev, severities: next }; + }); + }; + + return ( + <> +
+ 0} + onClick={() => + setDraft((prev) => ({ + ...prev, + severities: + prev.severities.size > 0 + ? new Set() + : new Set(WORK_ORDER_SEVERITIES), + })) + } + /> +
+
+ {WORK_ORDER_SEVERITIES.map((level) => { + const meta = SEVERITY_LEVELS[level]; + const color = SEVERITY_TOKEN_COLOR[meta.token]; + const selected = draft.severities.has(level); + return ( + + ); + })} +
+ + ); +} diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index a01eed47..ac8dc872 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -1,4 +1,6 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list"; +import type { WorkOrderSeverity } from "./work-order-severity"; +import type { UpliftFilterStatus } from "./work-order-uplift-filter"; import { ALL_WIZARD_STATUSES, WO_TYPE_META, @@ -26,11 +28,19 @@ export interface WorkOrderAdvancedFilters { customTo: string; sites: Set; types: Set; + severities: Set; dispatchers: Set; statuses: Set; + rescheduled: boolean; + carriedOver: boolean; + addOn: boolean; pmTypes: Set; vendorTechs: Set; docs: Set; + flagColors: Set; + internalOnly: boolean; + hasUplift: boolean; + upliftStatuses: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { @@ -39,11 +49,19 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { customTo: "", sites: new Set(), types: new Set(), + severities: new Set(), dispatchers: new Set(), statuses: new Set(), + rescheduled: false, + carriedOver: false, + addOn: false, pmTypes: new Set(), vendorTechs: new Set(), docs: new Set(), + flagColors: new Set(), + internalOnly: false, + hasUplift: false, + upliftStatuses: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ @@ -71,4 +89,11 @@ export const WO_TYPE_DESCRIPTIONS: Record = Object.fromEntries( export { ALL_WIZARD_STATUSES, WO_TYPES }; export type AdvancedFilterSetKey = - "sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs"; + | "sites" + | "types" + | "dispatchers" + | "statuses" + | "pmTypes" + | "vendorTechs" + | "docs" + | "flagColors"; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..bce4cac1 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -234,6 +234,14 @@ export interface WorkOrderBoardSearchQueryParams { pmTypes?: string[]; vendorIds?: number[]; docStatuses?: number[]; + severities?: number[]; + rescheduled?: boolean; + carriedOver?: boolean; + addOn?: boolean; + flagColors?: string[]; + internalOnly?: boolean; + hasUplift?: boolean; + upliftStatuses?: string[]; myWorkOrders?: boolean; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; diff --git a/src/domain/work-orders/types/work-order-severity.ts b/src/domain/work-orders/types/work-order-severity.ts new file mode 100644 index 00000000..94c3963e --- /dev/null +++ b/src/domain/work-orders/types/work-order-severity.ts @@ -0,0 +1,75 @@ +export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5; + +export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5]; + +/** Types that require an APM severity transcription. */ +export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const; + +export type SeverityWoType = (typeof SEVERITY_TYPES)[number]; + +export type SeverityToken = "critical" | "warning" | "safe"; + +export const SEVERITY_LEVELS: Record< + WorkOrderSeverity, + { + label: string; + description: string; + deadline: string; + afterHours: string; + token: SeverityToken; + } +> = { + 1: { + label: "SEV 1", + description: "Business Critical Function Down", + deadline: "Accept: 15 min · Respond: 2 hrs", + afterHours: "Yes", + token: "critical", + }, + 2: { + label: "SEV 2", + description: "Business Critical Function Impaired", + deadline: "Accept: 30 min · Respond: 4 hrs", + afterHours: "Yes", + token: "critical", + }, + 3: { + label: "SEV 3", + description: "Group Productivity Impaired", + deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)", + afterHours: "Approval required", + token: "warning", + }, + 4: { + label: "SEV 4", + description: "Individual Productivity Impaired", + deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)", + afterHours: "No", + token: "safe", + }, + 5: { + label: "SEV 5", + description: "Productivity not immediately affected", + deadline: "Accept: 12 hrs · Respond: 72 hrs", + afterHours: "No", + token: "safe", + }, +}; + +export const SEVERITY_TOKEN_COLOR: Record = { + critical: "var(--sla-critical, #DC2626)", + warning: "var(--sla-warning, #D97706)", + safe: "var(--sla-safe, #16A34A)", +}; + +export function typeNeedsSeverity(type: string): boolean { + return (SEVERITY_TYPES as readonly string[]).includes(type); +} + +export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) { + return n; + } + return null; +} diff --git a/src/domain/work-orders/types/work-order-uplift-filter.ts b/src/domain/work-orders/types/work-order-uplift-filter.ts new file mode 100644 index 00000000..a26ca0d1 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift-filter.ts @@ -0,0 +1,16 @@ +/** Uplift statuses exposed in advanced search (excludes cancelled/revoked). */ +export type UpliftFilterStatus = "pending" | "approved" | "auto_approved" | "rejected"; + +export const UPLIFT_FILTER_STATUSES: UpliftFilterStatus[] = [ + "pending", + "approved", + "auto_approved", + "rejected", +]; + +export const UPLIFT_FILTER_LABELS: Record = { + pending: "Pending", + approved: "Approved", + auto_approved: "Auto-approved", + rejected: "Rejected", +}; diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts new file mode 100644 index 00000000..8150c9d2 --- /dev/null +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -0,0 +1,283 @@ +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; +import { + ASSIGNEE_FILTER_UNASSIGNED, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + type CompletionDocFilter, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + SEVERITY_LEVELS, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import { + UPLIFT_FILTER_LABELS, + type UpliftFilterStatus, +} from "@/domain/work-orders/types/work-order-uplift-filter"; +import { MONTH_NAMES } from "@/domain/work-orders/utils/wizard-date-utils"; + +export interface AdvancedFilterChip { + key: string; + group: string; + value?: string; + count?: number; + accent?: boolean; + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; +} + +type ChipBuilderContext = { + siteNameOf: (id: string) => string; + dispatcherNameOf: (id: string) => string; +}; + +type SetChipInput = { + chips: AdvancedFilterChip[]; + key: string; + group: string; + set: Set; + nameOf: (value: string) => string; + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; +}; + +function pushSetChip(input: SetChipInput): void { + const { chips, key, group, set, nameOf, clear } = input; + if (!set.size) return; + if (set.size <= 2) { + chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear }); + return; + } + chips.push({ key, group, count: set.size, clear }); +} + +function fmtChipDate(iso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ""; + const [, m, d] = iso.split("-").map(Number); + return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`; +} + +function severityLabel(level: WorkOrderSeverity): string { + return SEVERITY_LEVELS[level].label; +} + +export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (filters.dateRange === "this-week") return; + const value = + filters.dateRange === "custom" + ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` + : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); + chips.push({ + key: "date", + group: "Date", + value, + clear: (current) => ({ + ...current, + dateRange: "this-week", + customFrom: "", + customTo: "", + }), + }); +} + +export function appendSetChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, + ctx: ChipBuilderContext, +) { + pushSetChip({ + chips, + key: "sites", + group: "Site", + set: filters.sites, + nameOf: ctx.siteNameOf, + clear: (current) => ({ ...current, sites: new Set() }), + }); + + pushSetChip({ + chips, + key: "types", + group: "Type of WO", + set: filters.types as Set, + nameOf: (v) => v, + clear: (current) => ({ ...current, types: new Set() }), + }); +} + +export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (!filters.severities?.size) return; + const clearSeverity = (current: WorkOrderAdvancedFilters) => ({ + ...current, + severities: new Set(), + }); + if (filters.severities.size <= 2) { + chips.push({ + key: "severity", + group: "Severity", + value: [...filters.severities].map(severityLabel).join(", "), + clear: clearSeverity, + }); + return; + } + chips.push({ + key: "severity", + group: "Severity", + count: filters.severities.size, + clear: clearSeverity, + }); +} + +export function appendDispatcherChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, + ctx: ChipBuilderContext, +) { + const realDispatchers = new Set( + [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED), + ); + pushSetChip({ + chips, + key: "disp", + group: "Assigned to", + set: realDispatchers, + nameOf: ctx.dispatcherNameOf, + clear: (current) => ({ + ...current, + dispatchers: new Set( + [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED), + ), + }), + }); + + if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) { + chips.push({ + key: "disp-unassigned", + group: "Assigned to", + value: "Unassigned", + accent: true, + clear: (current) => { + const next = new Set(current.dispatchers); + next.delete(ASSIGNEE_FILTER_UNASSIGNED); + return { ...current, dispatchers: next }; + }, + }); + } +} + +export function appendStatusPmVendorDocChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + pushSetChip({ + chips, + key: "status", + group: "Status", + set: filters.statuses as Set, + nameOf: (v) => v, + clear: (current) => ({ ...current, statuses: new Set() }), + }); + + pushSetChip({ + chips, + key: "pm", + group: "PM type", + set: filters.pmTypes, + nameOf: (v) => v, + clear: (current) => ({ ...current, pmTypes: new Set() }), + }); + + pushSetChip({ + chips, + key: "vendor", + group: "Vendor / tech", + set: filters.vendorTechs, + nameOf: (v) => v, + clear: (current) => ({ ...current, vendorTechs: new Set() }), + }); + + pushSetChip({ + chips, + key: "doc", + group: "Comp doc", + set: filters.docs as Set, + nameOf: (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v, + clear: (current) => ({ ...current, docs: new Set() }), + }); +} + +export function appendIndicatorChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + if (filters.rescheduled) { + chips.push({ + key: "rescheduled", + group: "Indicator", + value: "Rescheduled", + clear: (current) => ({ ...current, rescheduled: false }), + }); + } + if (filters.carriedOver) { + chips.push({ + key: "carriedover", + group: "Indicator", + value: "Carried Over", + clear: (current) => ({ ...current, carriedOver: false }), + }); + } + if (filters.addOn) { + chips.push({ + key: "addon", + group: "Indicator", + value: "Add-On", + clear: (current) => ({ ...current, addOn: false }), + }); + } +} + +export function appendFlagAndInternalChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + pushSetChip({ + chips, + key: "flags", + group: "Flag", + set: filters.flagColors, + nameOf: (v) => FLAG_COLORS.find((c) => c.value === v)?.name ?? v, + clear: (current) => ({ ...current, flagColors: new Set() }), + }); + + if (filters.internalOnly) { + chips.push({ + key: "internal", + group: "WO#", + value: "Internal (SH)", + clear: (current) => ({ ...current, internalOnly: false }), + }); + } +} + +export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (!filters.hasUplift) return; + const clearUplift = (current: WorkOrderAdvancedFilters) => ({ + ...current, + hasUplift: false, + upliftStatuses: new Set(), + }); + if (filters.upliftStatuses.size) { + pushSetChip({ + chips, + key: "uplift", + group: "Uplift", + set: filters.upliftStatuses as Set, + nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v, + clear: clearUplift, + }); + return; + } + chips.push({ + key: "uplift", + group: "Uplift", + value: "Has uplift", + clear: clearUplift, + }); +} diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 13c5aa1b..112204c8 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -44,6 +44,18 @@ const matchesVendorTech: AdvancedFilterPredicate = (row, filters) => const matchesDoc: AdvancedFilterPredicate = (row, filters) => !filters.docs.size || filters.docs.has(row.docStatus as never); +const matchesRescheduled: AdvancedFilterPredicate = (row, filters) => + !filters.rescheduled || row.rescheduleCount >= 2; + +const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) => + !filters.carriedOver || row.carriedOver >= 2; + +const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) => + !filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH"); + +const matchesFlagColors: AdvancedFilterPredicate = (row, filters) => + !filters.flagColors.size || (!!row.flagColor && filters.flagColors.has(row.flagColor)); + /** One predicate per filter dimension; a row matches when every predicate is true. */ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesDateRange, @@ -54,4 +66,8 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesPmType, matchesVendorTech, matchesDoc, + matchesRescheduled, + matchesCarriedOver, + matchesInternalOnly, + matchesFlagColors, ]; diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index ad132df4..8266a7b0 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -1,59 +1,60 @@ import { - ASSIGNEE_FILTER_UNASSIGNED, - DATE_RANGE_OPTIONS, - DOC_FILTER_LABELS, EMPTY_ADVANCED_FILTERS, - type CompletionDocFilter, type DateRangeKey, type WorkOrderAdvancedFilters, } from "@/domain/work-orders/types/work-order-advanced-filters"; import { - MONTH_NAMES, - addDaysIso, - mondayOf, - pad2, - todayIso, -} from "@/domain/work-orders/utils/wizard-date-utils"; + appendDateChip, + appendDispatcherChips, + appendFlagAndInternalChips, + appendIndicatorChips, + appendSetChips, + appendSeverityChip, + appendStatusPmVendorDocChips, + appendUpliftChip, + type AdvancedFilterChip, +} from "@/domain/work-orders/utils/advanced-filter-chip-builders"; +import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; -export interface AdvancedFilterChip { - key: string; - group: string; - value?: string; - count?: number; - accent?: boolean; - clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; -} +export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters { return { + ...EMPTY_ADVANCED_FILTERS, ...filters, sites: new Set(filters.sites), types: new Set(filters.types), + severities: new Set(filters.severities ?? []), dispatchers: new Set(filters.dispatchers), statuses: new Set(filters.statuses), pmTypes: new Set(filters.pmTypes), vendorTechs: new Set(filters.vendorTechs), docs: new Set(filters.docs), + flagColors: new Set(filters.flagColors ?? []), + upliftStatuses: new Set(filters.upliftStatuses ?? []), }; } -export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { - let count = 0; - if (filters.dateRange !== "this-week") count += 1; - if (filters.sites.size) count += 1; - if (filters.types.size) count += 1; - if (filters.dispatchers.size) count += 1; - if (filters.statuses.size) count += 1; - if (filters.pmTypes.size) count += 1; - if (filters.vendorTechs.size) count += 1; - if (filters.docs.size) count += 1; - return count; -} +const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [ + filters.dateRange !== "this-week", + Boolean(filters.sites?.size), + Boolean(filters.types?.size), + Boolean(filters.severities?.size), + Boolean(filters.dispatchers?.size), + Boolean(filters.statuses?.size), + filters.rescheduled, + filters.carriedOver, + filters.addOn, + Boolean(filters.pmTypes?.size), + Boolean(filters.vendorTechs?.size), + Boolean(filters.docs?.size), + Boolean(filters.flagColors?.size), + filters.internalOnly, + filters.hasUplift, +]; -function fmtChipDate(iso: string): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ""; - const [, m, d] = iso.split("-").map(Number); - return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`; +export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { + return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length; } function monthStart(y: number, m: number): string { @@ -91,22 +92,6 @@ export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string } } -function setChip( - chips: AdvancedFilterChip[], - key: string, - group: string, - set: Set, - nameOf: (value: string) => string, - clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters, -) { - if (!set.size) return; - if (set.size <= 2) { - chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear }); - return; - } - chips.push({ key, group, count: set.size, clear }); -} - export function buildAdvancedFilterChips( filters: WorkOrderAdvancedFilters, options?: { @@ -115,100 +100,19 @@ export function buildAdvancedFilterChips( }, ): AdvancedFilterChip[] { const chips: AdvancedFilterChip[] = []; - const siteNameOf = options?.siteNameOf ?? ((v) => v); - const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v); + const ctx = { + siteNameOf: options?.siteNameOf ?? ((v: string) => v), + dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v), + }; - if (filters.dateRange !== "this-week") { - const value = - filters.dateRange === "custom" - ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` - : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); - chips.push({ - key: "date", - group: "Date", - value, - clear: (current) => ({ - ...current, - dateRange: "this-week", - customFrom: "", - customTo: "", - }), - }); - } - - setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({ - ...current, - sites: new Set(), - })); - - setChip( - chips, - "types", - "Type of WO", - filters.types as Set, - (v) => v, - (current) => ({ ...current, types: new Set() }), - ); - - const realDispatchers = new Set( - [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED), - ); - setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({ - ...current, - dispatchers: new Set( - [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED), - ), - })); - - if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) { - chips.push({ - key: "disp-unassigned", - group: "Assigned to", - value: "Unassigned", - accent: true, - clear: (current) => { - const next = new Set(current.dispatchers); - next.delete(ASSIGNEE_FILTER_UNASSIGNED); - return { ...current, dispatchers: next }; - }, - }); - } - - setChip( - chips, - "status", - "Status", - filters.statuses as Set, - (v) => v, - (current) => ({ ...current, statuses: new Set() }), - ); - - setChip( - chips, - "pm", - "PM type", - filters.pmTypes, - (v) => v, - (current) => ({ ...current, pmTypes: new Set() }), - ); - - setChip( - chips, - "vendor", - "Vendor / tech", - filters.vendorTechs, - (v) => v, - (current) => ({ ...current, vendorTechs: new Set() }), - ); - - setChip( - chips, - "doc", - "Comp doc", - filters.docs as Set, - (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v, - (current) => ({ ...current, docs: new Set() }), - ); + appendDateChip(chips, filters); + appendSetChips(chips, filters, ctx); + appendSeverityChip(chips, filters); + appendDispatcherChips(chips, filters, ctx); + appendStatusPmVendorDocChips(chips, filters); + appendIndicatorChips(chips, filters); + appendFlagAndInternalChips(chips, filters); + appendUpliftChip(chips, filters); return chips; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..ba8d6701 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -77,6 +77,47 @@ function mapDocStatuses(docs: Iterable): number[] | undefin return list.length > 0 ? list : undefined; } +function resolveVendorIds( + vendorTechs: Iterable, + vendorIdsByCompanyName?: Map, +): number[] | undefined { + if (!vendorIdsByCompanyName) return undefined; + const vendorIds: number[] = []; + for (const name of vendorTechs) { + const id = vendorIdsByCompanyName.get(name); + if (typeof id === "number") { + vendorIds.push(id); + } + } + return vendorIds.length > 0 ? vendorIds : undefined; +} + +function mapSearchFacetParams( + filters: WorkOrderAdvancedFilters, +): Pick< + WorkOrderBoardSearchQueryParams, + | "severities" + | "rescheduled" + | "carriedOver" + | "addOn" + | "flagColors" + | "internalOnly" + | "hasUplift" + | "upliftStatuses" +> { + return { + severities: filters.severities?.size ? [...filters.severities] : undefined, + rescheduled: filters.rescheduled || undefined, + carriedOver: filters.carriedOver || undefined, + addOn: filters.addOn || undefined, + flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined, + internalOnly: filters.internalOnly || undefined, + hasUplift: filters.hasUplift || undefined, + upliftStatuses: + filters.hasUplift && filters.upliftStatuses?.size ? [...filters.upliftStatuses] : undefined, + }; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, @@ -113,23 +154,14 @@ export function toBoardSearchQueryParams(input: { 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 = { page: input.page ?? 0, pageSize, - search: - input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined, + search, datePreset, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, types: typeFilters.types, @@ -137,8 +169,9 @@ export function toBoardSearchQueryParams(input: { 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", }; diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx new file mode 100644 index 00000000..8f9fc28d --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; + +describe("WorkOrdersAdvancedFiltersBanner count semantics", () => { + it("labels the banner with search totalCount, not the current page length", () => { + render( + undefined} + onClearAll={() => undefined} + />, + ); + + expect(screen.getByText("150 work orders")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index ab94481a..74d58e8d 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -2,6 +2,10 @@ import { fireEvent, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import WorkOrdersListPage from "@/app/(protected)/workorders/index"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + BOARD_SEARCH_MAX_PAGE_SIZE, + toBoardSearchQueryParams, +} from "@/domain/work-orders/utils/board-query-params"; import { renderWithProviders } from "@/test/test-utils"; const setAdvOpen = vi.fn(); @@ -151,4 +155,38 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { ); expect(clearAdvancedFilters).toHaveBeenCalled(); }); + + it("applies Severity and Has uplift through the public Advanced Filters sheet", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /Severity/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /SEV 1/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /^Uplift/i })); + fireEvent.click(within(sheet).getByLabelText(/Has uplift/i)); + fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + + expect(applyAdvancedFilters).toHaveBeenCalled(); + const applied = applyAdvancedFilters.mock.calls.at(-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"]); + }); }); diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index f96d19ee..c4f351fa 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -100,4 +100,46 @@ describe("clearAdvancedFilterGroup", () => { const next = clearAdvancedFilterGroup(withSites("1"), "sites"); expect(next).toBeNull(); }); + + it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + severities: new Set([1, 2] as const), + rescheduled: true, + flagColors: new Set(["#EF4444"]), + internalOnly: true, + hasUplift: true, + upliftStatuses: new Set(["pending" as const]), + }; + + const chips = buildAdvancedFilterChips(filters); + expect(chips.some((c) => c.key === "severity" && c.value === "SEV 1, SEV 2")).toBe(true); + expect(chips.some((c) => c.key === "rescheduled" && c.value === "Rescheduled")).toBe(true); + expect(chips.some((c) => c.key === "flags" && c.value === "Red")).toBe(true); + expect(chips.some((c) => c.key === "internal" && c.value === "Internal (SH)")).toBe(true); + expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true); + }); + + it("clears uplift chip and drops hasUplift with statuses", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + hasUplift: true, + upliftStatuses: new Set(["pending" as const, "approved" as const]), + }; + + const next = clearAdvancedFilterGroup(filters, "uplift"); + expect(next).toBeNull(); + }); + + it("clears indicator chip without affecting other groups", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + rescheduled: true, + severities: new Set([3] as const), + }; + + const next = clearAdvancedFilterGroup(filters, "rescheduled"); + expect(next?.rescheduled).toBe(false); + expect(next?.severities.has(3)).toBe(true); + }); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..31461891 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -147,6 +147,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", () => {