mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
Merge branch 'dev' into feature/wo-multi-poc-contacts
This commit is contained in:
commit
5718ca2b92
21 changed files with 1081 additions and 221 deletions
|
|
@ -57,6 +57,50 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
|||
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
|
||||
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
|
||||
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
|
||||
| `severities` | `severities` | Integers `1`–`5` (Emergency/Reactive) |
|
||||
| `rescheduled` | `rescheduled` | `true` = reschedule count ≥ 2 |
|
||||
| `carriedOver` | `carriedOver` | `true` = carried-over count ≥ 2 |
|
||||
| `addOn` | `addOn` | `true` = `isAddOn` indicator |
|
||||
| `flagColors` | `flagColors` | Multi `#RRGGBB` (board flag palette) |
|
||||
| `internalOnly` | `internalOnly` | `true` = WO# starts with `SH` |
|
||||
| `hasUplift` | `hasUplift` | `true` = WO has standing uplift |
|
||||
| `upliftStatuses` | `upliftStatuses` | With `hasUplift`; see below |
|
||||
|
||||
### `severities`
|
||||
|
||||
Multi-select SEV 1–5. Applies to Emergency/Reactive work orders with a transcribed APM severity.
|
||||
|
||||
### Indicators (`rescheduled`, `carriedOver`, `addOn`)
|
||||
|
||||
Boolean flags matching board badge thresholds (counters ≥ 2 for reschedule/carried-over). `addOn` filters the `isAddOn` indicator (not a WO type).
|
||||
|
||||
### `flagColors`
|
||||
|
||||
Repeated `#RRGGBB` values from the fixed board flag palette (`WorkOrderFlagColors`).
|
||||
|
||||
### `internalOnly`
|
||||
|
||||
When `true`, only work orders whose number starts with `SH` (internal placeholder).
|
||||
|
||||
### Uplift filters
|
||||
|
||||
| FE state | API param | Regra |
|
||||
| ---------------- | ---------------- | ------------------------------------------------ |
|
||||
| `hasUplift` | `hasUplift` | `true` = WO has non-cancelled/non-revoked uplift |
|
||||
| `upliftStatuses` | `upliftStatuses` | Optional refinement when `hasUplift=true` |
|
||||
|
||||
Allowed `upliftStatuses`: `pending`, `approved`, `auto_approved`, `rejected` (excludes cancelled/revoked).
|
||||
|
||||
Exemplo: pending uplifts only — `hasUplift=true&upliftStatuses=pending`.
|
||||
|
||||
## Backend producer (`shoc-backend` `dev`)
|
||||
|
||||
These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/dev` ([#45](https://github.com/Sea-Haven-Industries/shoc-backend/pull/45), [#65](https://github.com/Sea-Haven-Industries/shoc-backend/pull/65)). There is no frontend deployment fence.
|
||||
|
||||
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
|
||||
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
|
||||
- Pagination matches this contract: `page` is 0-based; `pageSize` default/max is 100.
|
||||
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
|
||||
|
||||
### `datePreset`
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { AdvFilterPill } from "./adv-filter-pill";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { DateField } from "@/components/ui/date-input";
|
||||
import {
|
||||
DATE_RANGE_OPTIONS,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
|
||||
type AdvDateRangeSectionProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
};
|
||||
|
||||
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
|
||||
return (
|
||||
<AdvSection
|
||||
icon={CalendarDays}
|
||||
label="Date range"
|
||||
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DATE_RANGE_OPTIONS.map((option) => (
|
||||
<AdvFilterPill
|
||||
key={option.key}
|
||||
label={option.label}
|
||||
selected={draft.dateRange === option.key}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{draft.dateRange === "custom" && (
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
<DateField
|
||||
label="From"
|
||||
value={draft.customFrom}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
|
||||
/>
|
||||
<DateField
|
||||
label="To"
|
||||
value={draft.customTo}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Flame,
|
||||
|
|
@ -10,24 +10,24 @@ import {
|
|||
UserX,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||
import { AdvMultiSelect } from "./adv-multi-select";
|
||||
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { AdvSeverityList } from "./adv-severity-list";
|
||||
import { AdvTypeGrid } from "./adv-type-grid";
|
||||
import { DateField } from "@/components/ui/date-input";
|
||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
COMPLETION_DOC_OPTIONS,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
type AdvancedFilterSetKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
|
||||
type AdvancedSearchFilterSectionsProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
|
|
@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({
|
|||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<AdvSection
|
||||
icon={CalendarDays}
|
||||
label="Date range"
|
||||
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DATE_RANGE_OPTIONS.map((option) => (
|
||||
<AdvFilterPill
|
||||
key={option.key}
|
||||
label={option.label}
|
||||
selected={draft.dateRange === option.key}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{draft.dateRange === "custom" && (
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
<DateField
|
||||
label="From"
|
||||
value={draft.customFrom}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customFrom: value }))}
|
||||
/>
|
||||
<DateField
|
||||
label="To"
|
||||
value={draft.customTo}
|
||||
onChange={(value) => setDraft((prev) => ({ ...prev, customTo: value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
|
||||
<AdvMultiSelect
|
||||
|
|
@ -113,6 +84,10 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={AlertTriangle} label="Severity" count={draft.severities.size}>
|
||||
<AdvSeverityList draft={draft} setDraft={setDraft} />
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
|
||||
<AdvMultiSelect
|
||||
options={[
|
||||
|
|
@ -149,6 +124,13 @@ export function AdvancedSearchFilterSections({
|
|||
</div>
|
||||
</AdvSection>
|
||||
|
||||
<AdvParityFilterSections
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
toggleIn={toggleIn}
|
||||
setMany={setMany}
|
||||
/>
|
||||
|
||||
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
||||
<AdvMultiSelect
|
||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react";
|
||||
import { Checkbox } from "@mui/material";
|
||||
import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill";
|
||||
import { AdvMultiSelect } from "./adv-multi-select";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||
import {
|
||||
type AdvancedFilterSetKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
UPLIFT_FILTER_LABELS,
|
||||
UPLIFT_FILTER_STATUSES,
|
||||
type UpliftFilterStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||
|
||||
type AdvParityFilterSectionsProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
|
||||
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
|
||||
};
|
||||
|
||||
function indicatorCount(draft: WorkOrderAdvancedFilters): number {
|
||||
return (draft.rescheduled ? 1 : 0) + (draft.carriedOver ? 1 : 0) + (draft.addOn ? 1 : 0);
|
||||
}
|
||||
|
||||
function FlagColorDot({ value }: { value: string }) {
|
||||
return (
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full"
|
||||
style={{ background: value, border: "1px solid var(--color-border)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvParityFilterSections({
|
||||
draft,
|
||||
setDraft,
|
||||
toggleIn,
|
||||
setMany,
|
||||
}: AdvParityFilterSectionsProps) {
|
||||
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.upliftStatuses);
|
||||
if (next.has(status)) {
|
||||
next.delete(status);
|
||||
} else {
|
||||
next.add(status);
|
||||
}
|
||||
return { ...prev, upliftStatuses: next };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<AdvFilterPill
|
||||
label="Rescheduled ≥ 2"
|
||||
selected={draft.rescheduled}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
<AdvFilterPill
|
||||
label="Carried Over ≥ 2"
|
||||
selected={draft.carriedOver}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
<AdvFilterPill
|
||||
label="Add-On"
|
||||
selected={draft.addOn}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
|
||||
<AdvMultiSelect
|
||||
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}
|
||||
selected={draft.flagColors}
|
||||
onToggle={(value) => toggleIn("flagColors", value)}
|
||||
onBulkToggle={(values, select) => setMany("flagColors", values, select)}
|
||||
placeholder="Search flag colour…"
|
||||
renderLeft={(value) => <FlagColorDot value={value} />}
|
||||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={Hash} label="Work Order Number" count={draft.internalOnly ? 1 : 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, internalOnly: !prev.internalOnly }))}
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
>
|
||||
<span
|
||||
className="flex size-[18px] shrink-0 items-center justify-center rounded border"
|
||||
style={{
|
||||
borderColor: draft.internalOnly ? "var(--color-primary)" : "var(--color-border)",
|
||||
background: draft.internalOnly ? "var(--color-primary)" : "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{Boolean(draft.internalOnly) && <Check size={12} className="text-white" />}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Internal numbers only (SH prefix)</span>
|
||||
</button>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none">
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={draft.hasUplift}
|
||||
onChange={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
hasUplift: !prev.hasUplift,
|
||||
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
padding: 0,
|
||||
color: "var(--color-border-hover)",
|
||||
"&.Mui-checked": { color: "var(--color-primary)" },
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Has uplift</span>
|
||||
</label>
|
||||
{Boolean(draft.hasUplift) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{UPLIFT_FILTER_STATUSES.map((status) => (
|
||||
<AdvDocPill
|
||||
key={status}
|
||||
label={UPLIFT_FILTER_LABELS[status]}
|
||||
selected={draft.upliftStatuses.has(status)}
|
||||
onClick={() => toggleUpliftStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AdvSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_TOKEN_COLOR,
|
||||
WORK_ORDER_SEVERITIES,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
|
||||
type AdvSeverityListProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
};
|
||||
|
||||
export function AdvSeverityList({ draft, setDraft }: AdvSeverityListProps) {
|
||||
const toggleLevel = (level: WorkOrderSeverity) => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.severities);
|
||||
if (next.has(level)) {
|
||||
next.delete(level);
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return { ...prev, severities: next };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex justify-end">
|
||||
<SelectAllAction
|
||||
anySelected={draft.severities.size > 0}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
severities:
|
||||
prev.severities.size > 0
|
||||
? new Set()
|
||||
: new Set<WorkOrderSeverity>(WORK_ORDER_SEVERITIES),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{WORK_ORDER_SEVERITIES.map((level) => {
|
||||
const meta = SEVERITY_LEVELS[level];
|
||||
const color = SEVERITY_TOKEN_COLOR[meta.token];
|
||||
const selected = draft.severities.has(level);
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => toggleLevel(level)}
|
||||
className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-colors"
|
||||
style={{
|
||||
borderColor: selected ? color : "var(--color-border)",
|
||||
background: selected ? "var(--color-bg-muted)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded"
|
||||
style={{
|
||||
border: `1px solid ${selected ? color : "var(--color-border)"}`,
|
||||
background: selected ? color : "transparent",
|
||||
}}
|
||||
>
|
||||
{Boolean(selected) && <Check size={11} className="text-white" />}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded border px-1.5 py-px text-[11px] font-semibold whitespace-nowrap"
|
||||
style={{ color, borderColor: color }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="flex-1 text-xs text-foreground">{meta.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ type DispatcherFilterProps = {
|
|||
users: WorkOrderUser[];
|
||||
usersLoading?: boolean;
|
||||
currentUserId?: string | number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function userInitials(name: string): string {
|
||||
|
|
@ -40,6 +41,7 @@ export function DispatcherFilter({
|
|||
users,
|
||||
usersLoading = false,
|
||||
currentUserId,
|
||||
disabled = false,
|
||||
}: DispatcherFilterProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
||||
|
|
@ -86,6 +88,7 @@ export function DispatcherFilter({
|
|||
<MenuAnchorButton
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
|
||||
style={
|
||||
!allSelected
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ type WeekNavProps = {
|
|||
weekMonday: string;
|
||||
|
||||
onChange: (mondayIso: string) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const VISIBLE_COUNT = 3;
|
||||
|
|
@ -51,7 +53,7 @@ function WeekLiveBadge() {
|
|||
);
|
||||
}
|
||||
|
||||
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
||||
export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
|
||||
const weeks = useMemo(
|
||||
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<NavArrowButton
|
||||
disabled={!canGoBack}
|
||||
disabled={disabled || !canGoBack}
|
||||
onClick={() => setOffset((current) => current - 1)}
|
||||
aria-label="Previous weeks"
|
||||
>
|
||||
|
|
@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
<NavPillButton
|
||||
key={week.mondayIso}
|
||||
active={isActive}
|
||||
disabled={isDisabled}
|
||||
disabled={disabled || isDisabled}
|
||||
onClick={() => onChange(week.mondayIso)}
|
||||
>
|
||||
{week.label}
|
||||
|
|
@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
|
|||
})}
|
||||
|
||||
<NavArrowButton
|
||||
disabled={!canGoForward}
|
||||
disabled={disabled || !canGoForward}
|
||||
onClick={() => setOffset((current) => current + 1)}
|
||||
aria-label="Next weeks"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@ type WoTypeSegmentProps = {
|
|||
value: WorkOrderTypeFilter;
|
||||
|
||||
onChange: (value: WorkOrderTypeFilter) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
|
||||
|
||||
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
|
||||
export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) {
|
||||
return (
|
||||
<SegmentGroup>
|
||||
{OPTIONS.map((option) => (
|
||||
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
|
||||
<SegmentButton
|
||||
key={option}
|
||||
active={value === option}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option)}
|
||||
>
|
||||
{option}
|
||||
</SegmentButton>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type WorkOrdersFilterBarProps = {
|
|||
currentUserId?: string | number;
|
||||
filteredCount: number;
|
||||
scopeTotalCount: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrdersFilterBar({
|
||||
|
|
@ -30,20 +31,25 @@ export function WorkOrdersFilterBar({
|
|||
currentUserId,
|
||||
filteredCount,
|
||||
scopeTotalCount,
|
||||
disabled = false,
|
||||
}: WorkOrdersFilterBarProps) {
|
||||
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
|
||||
<div
|
||||
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<DispatcherFilter
|
||||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
|
||||
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
|
||||
<div className="flex-1" />
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export default function WorkOrdersListPage() {
|
|||
currentUserId={user?.id}
|
||||
filteredCount={tableData.totalCount}
|
||||
scopeTotalCount={tableData.scopeTotalCount}
|
||||
disabled={filters.advApplied != null}
|
||||
/>
|
||||
|
||||
{filters.advApplied != null && (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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",
|
||||
};
|
||||
283
src/domain/work-orders/utils/advanced-filter-chip-builders.ts
Normal file
283
src/domain/work-orders/utils/advanced-filter-chip-builders.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||
import {
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
type CompletionDocFilter,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import {
|
||||
UPLIFT_FILTER_LABELS,
|
||||
type UpliftFilterStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||
import { MONTH_NAMES } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export interface AdvancedFilterChip {
|
||||
key: string;
|
||||
group: string;
|
||||
value?: string;
|
||||
count?: number;
|
||||
accent?: boolean;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
}
|
||||
|
||||
type ChipBuilderContext = {
|
||||
siteNameOf: (id: string) => string;
|
||||
dispatcherNameOf: (id: string) => string;
|
||||
};
|
||||
|
||||
type SetChipInput = {
|
||||
chips: AdvancedFilterChip[];
|
||||
key: string;
|
||||
group: string;
|
||||
set: Set<string>;
|
||||
nameOf: (value: string) => string;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
};
|
||||
|
||||
function pushSetChip(input: SetChipInput): void {
|
||||
const { chips, key, group, set, nameOf, clear } = input;
|
||||
if (!set.size) return;
|
||||
if (set.size <= 2) {
|
||||
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
|
||||
return;
|
||||
}
|
||||
chips.push({ key, group, count: set.size, clear });
|
||||
}
|
||||
|
||||
function fmtChipDate(iso: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
||||
}
|
||||
|
||||
function severityLabel(level: WorkOrderSeverity): string {
|
||||
return SEVERITY_LEVELS[level].label;
|
||||
}
|
||||
|
||||
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (filters.dateRange === "this-week") return;
|
||||
const value =
|
||||
filters.dateRange === "custom"
|
||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
||||
chips.push({
|
||||
key: "date",
|
||||
group: "Date",
|
||||
value,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dateRange: "this-week",
|
||||
customFrom: "",
|
||||
customTo: "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendSetChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
ctx: ChipBuilderContext,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "sites",
|
||||
group: "Site",
|
||||
set: filters.sites,
|
||||
nameOf: ctx.siteNameOf,
|
||||
clear: (current) => ({ ...current, sites: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "types",
|
||||
group: "Type of WO",
|
||||
set: filters.types as Set<string>,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, types: new Set() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (!filters.severities?.size) return;
|
||||
const clearSeverity = (current: WorkOrderAdvancedFilters) => ({
|
||||
...current,
|
||||
severities: new Set<WorkOrderSeverity>(),
|
||||
});
|
||||
if (filters.severities.size <= 2) {
|
||||
chips.push({
|
||||
key: "severity",
|
||||
group: "Severity",
|
||||
value: [...filters.severities].map(severityLabel).join(", "),
|
||||
clear: clearSeverity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chips.push({
|
||||
key: "severity",
|
||||
group: "Severity",
|
||||
count: filters.severities.size,
|
||||
clear: clearSeverity,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendDispatcherChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
ctx: ChipBuilderContext,
|
||||
) {
|
||||
const realDispatchers = new Set(
|
||||
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
|
||||
);
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "disp",
|
||||
group: "Assigned to",
|
||||
set: realDispatchers,
|
||||
nameOf: ctx.dispatcherNameOf,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dispatchers: new Set(
|
||||
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
|
||||
chips.push({
|
||||
key: "disp-unassigned",
|
||||
group: "Assigned to",
|
||||
value: "Unassigned",
|
||||
accent: true,
|
||||
clear: (current) => {
|
||||
const next = new Set(current.dispatchers);
|
||||
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
|
||||
return { ...current, dispatchers: next };
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendStatusPmVendorDocChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "status",
|
||||
group: "Status",
|
||||
set: filters.statuses as Set<string>,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "pm",
|
||||
group: "PM type",
|
||||
set: filters.pmTypes,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, pmTypes: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "vendor",
|
||||
group: "Vendor / tech",
|
||||
set: filters.vendorTechs,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, vendorTechs: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "doc",
|
||||
group: "Comp doc",
|
||||
set: filters.docs as Set<string>,
|
||||
nameOf: (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
|
||||
clear: (current) => ({ ...current, docs: new Set() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendIndicatorChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
if (filters.rescheduled) {
|
||||
chips.push({
|
||||
key: "rescheduled",
|
||||
group: "Indicator",
|
||||
value: "Rescheduled",
|
||||
clear: (current) => ({ ...current, rescheduled: false }),
|
||||
});
|
||||
}
|
||||
if (filters.carriedOver) {
|
||||
chips.push({
|
||||
key: "carriedover",
|
||||
group: "Indicator",
|
||||
value: "Carried Over",
|
||||
clear: (current) => ({ ...current, carriedOver: false }),
|
||||
});
|
||||
}
|
||||
if (filters.addOn) {
|
||||
chips.push({
|
||||
key: "addon",
|
||||
group: "Indicator",
|
||||
value: "Add-On",
|
||||
clear: (current) => ({ ...current, addOn: false }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendFlagAndInternalChips(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "flags",
|
||||
group: "Flag",
|
||||
set: filters.flagColors,
|
||||
nameOf: (v) => FLAG_COLORS.find((c) => c.value === v)?.name ?? v,
|
||||
clear: (current) => ({ ...current, flagColors: new Set() }),
|
||||
});
|
||||
|
||||
if (filters.internalOnly) {
|
||||
chips.push({
|
||||
key: "internal",
|
||||
group: "WO#",
|
||||
value: "Internal (SH)",
|
||||
clear: (current) => ({ ...current, internalOnly: false }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
if (!filters.hasUplift) return;
|
||||
const clearUplift = (current: WorkOrderAdvancedFilters) => ({
|
||||
...current,
|
||||
hasUplift: false,
|
||||
upliftStatuses: new Set<UpliftFilterStatus>(),
|
||||
});
|
||||
if (filters.upliftStatuses.size) {
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "uplift",
|
||||
group: "Uplift",
|
||||
set: filters.upliftStatuses as Set<string>,
|
||||
nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v,
|
||||
clear: clearUplift,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chips.push({
|
||||
key: "uplift",
|
||||
group: "Uplift",
|
||||
value: "Has uplift",
|
||||
clear: clearUplift,
|
||||
});
|
||||
}
|
||||
|
|
@ -44,6 +44,18 @@ const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
|
|||
const matchesDoc: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.docs.size || filters.docs.has(row.docStatus as never);
|
||||
|
||||
const matchesRescheduled: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.rescheduled || row.rescheduleCount >= 2;
|
||||
|
||||
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.carriedOver || row.carriedOver >= 2;
|
||||
|
||||
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
|
||||
|
||||
const matchesFlagColors: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.flagColors.size || (!!row.flagColor && filters.flagColors.has(row.flagColor));
|
||||
|
||||
/** One predicate per filter dimension; a row matches when every predicate is true. */
|
||||
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||
matchesDateRange,
|
||||
|
|
@ -54,4 +66,8 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
|||
matchesPmType,
|
||||
matchesVendorTech,
|
||||
matchesDoc,
|
||||
matchesRescheduled,
|
||||
matchesCarriedOver,
|
||||
matchesInternalOnly,
|
||||
matchesFlagColors,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,59 +1,60 @@
|
|||
import {
|
||||
ASSIGNEE_FILTER_UNASSIGNED,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DOC_FILTER_LABELS,
|
||||
EMPTY_ADVANCED_FILTERS,
|
||||
type CompletionDocFilter,
|
||||
type DateRangeKey,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
MONTH_NAMES,
|
||||
addDaysIso,
|
||||
mondayOf,
|
||||
pad2,
|
||||
todayIso,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
appendDateChip,
|
||||
appendDispatcherChips,
|
||||
appendFlagAndInternalChips,
|
||||
appendIndicatorChips,
|
||||
appendSetChips,
|
||||
appendSeverityChip,
|
||||
appendStatusPmVendorDocChips,
|
||||
appendUpliftChip,
|
||||
type AdvancedFilterChip,
|
||||
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export interface AdvancedFilterChip {
|
||||
key: string;
|
||||
group: string;
|
||||
value?: string;
|
||||
count?: number;
|
||||
accent?: boolean;
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
|
||||
}
|
||||
export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||
|
||||
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
|
||||
return {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
...filters,
|
||||
sites: new Set(filters.sites),
|
||||
types: new Set(filters.types),
|
||||
severities: new Set(filters.severities ?? []),
|
||||
dispatchers: new Set(filters.dispatchers),
|
||||
statuses: new Set(filters.statuses),
|
||||
pmTypes: new Set(filters.pmTypes),
|
||||
vendorTechs: new Set(filters.vendorTechs),
|
||||
docs: new Set(filters.docs),
|
||||
flagColors: new Set(filters.flagColors ?? []),
|
||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||
let count = 0;
|
||||
if (filters.dateRange !== "this-week") count += 1;
|
||||
if (filters.sites.size) count += 1;
|
||||
if (filters.types.size) count += 1;
|
||||
if (filters.dispatchers.size) count += 1;
|
||||
if (filters.statuses.size) count += 1;
|
||||
if (filters.pmTypes.size) count += 1;
|
||||
if (filters.vendorTechs.size) count += 1;
|
||||
if (filters.docs.size) count += 1;
|
||||
return count;
|
||||
}
|
||||
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
|
||||
filters.dateRange !== "this-week",
|
||||
Boolean(filters.sites?.size),
|
||||
Boolean(filters.types?.size),
|
||||
Boolean(filters.severities?.size),
|
||||
Boolean(filters.dispatchers?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
Boolean(filters.pmTypes?.size),
|
||||
Boolean(filters.vendorTechs?.size),
|
||||
Boolean(filters.docs?.size),
|
||||
Boolean(filters.flagColors?.size),
|
||||
filters.internalOnly,
|
||||
filters.hasUplift,
|
||||
];
|
||||
|
||||
function fmtChipDate(iso: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function monthStart(y: number, m: number): string {
|
||||
|
|
@ -91,22 +92,6 @@ export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string
|
|||
}
|
||||
}
|
||||
|
||||
function setChip(
|
||||
chips: AdvancedFilterChip[],
|
||||
key: string,
|
||||
group: string,
|
||||
set: Set<string>,
|
||||
nameOf: (value: string) => string,
|
||||
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
|
||||
) {
|
||||
if (!set.size) return;
|
||||
if (set.size <= 2) {
|
||||
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
|
||||
return;
|
||||
}
|
||||
chips.push({ key, group, count: set.size, clear });
|
||||
}
|
||||
|
||||
export function buildAdvancedFilterChips(
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
options?: {
|
||||
|
|
@ -115,100 +100,19 @@ export function buildAdvancedFilterChips(
|
|||
},
|
||||
): AdvancedFilterChip[] {
|
||||
const chips: AdvancedFilterChip[] = [];
|
||||
const siteNameOf = options?.siteNameOf ?? ((v) => v);
|
||||
const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
|
||||
const ctx = {
|
||||
siteNameOf: options?.siteNameOf ?? ((v: string) => v),
|
||||
dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v),
|
||||
};
|
||||
|
||||
if (filters.dateRange !== "this-week") {
|
||||
const value =
|
||||
filters.dateRange === "custom"
|
||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
||||
chips.push({
|
||||
key: "date",
|
||||
group: "Date",
|
||||
value,
|
||||
clear: (current) => ({
|
||||
...current,
|
||||
dateRange: "this-week",
|
||||
customFrom: "",
|
||||
customTo: "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
|
||||
...current,
|
||||
sites: new Set(),
|
||||
}));
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"types",
|
||||
"Type of WO",
|
||||
filters.types as Set<string>,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, types: new Set() }),
|
||||
);
|
||||
|
||||
const realDispatchers = new Set(
|
||||
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
|
||||
);
|
||||
setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
|
||||
...current,
|
||||
dispatchers: new Set(
|
||||
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
|
||||
),
|
||||
}));
|
||||
|
||||
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
|
||||
chips.push({
|
||||
key: "disp-unassigned",
|
||||
group: "Assigned to",
|
||||
value: "Unassigned",
|
||||
accent: true,
|
||||
clear: (current) => {
|
||||
const next = new Set(current.dispatchers);
|
||||
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
|
||||
return { ...current, dispatchers: next };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"status",
|
||||
"Status",
|
||||
filters.statuses as Set<string>,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, statuses: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"pm",
|
||||
"PM type",
|
||||
filters.pmTypes,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, pmTypes: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"vendor",
|
||||
"Vendor / tech",
|
||||
filters.vendorTechs,
|
||||
(v) => v,
|
||||
(current) => ({ ...current, vendorTechs: new Set() }),
|
||||
);
|
||||
|
||||
setChip(
|
||||
chips,
|
||||
"doc",
|
||||
"Comp doc",
|
||||
filters.docs as Set<string>,
|
||||
(v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
|
||||
(current) => ({ ...current, docs: new Set() }),
|
||||
);
|
||||
appendDateChip(chips, filters);
|
||||
appendSetChips(chips, filters, ctx);
|
||||
appendSeverityChip(chips, filters);
|
||||
appendDispatcherChips(chips, filters, ctx);
|
||||
appendStatusPmVendorDocChips(chips, filters);
|
||||
appendIndicatorChips(chips, filters);
|
||||
appendFlagAndInternalChips(chips, filters);
|
||||
appendUpliftChip(chips, filters);
|
||||
|
||||
return chips;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
|
||||
describe("WorkOrdersAdvancedFiltersBanner count semantics", () => {
|
||||
it("labels the banner with search totalCount, not the current page length", () => {
|
||||
render(
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={{
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
severities: new Set([1]),
|
||||
hasUplift: true,
|
||||
}}
|
||||
totalCount={150}
|
||||
sites={[]}
|
||||
users={[]}
|
||||
onClearChip={() => undefined}
|
||||
onClearAll={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("150 work orders")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,10 @@ import { fireEvent, screen, within } from "@testing-library/react";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
toBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const setAdvOpen = vi.fn();
|
||||
|
|
@ -55,8 +59,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
|||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||
useWorkOrdersTableData: () => ({
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
scopeTotalCount: 0,
|
||||
totalCount: advApplied ? 42 : 0,
|
||||
scopeTotalCount: advApplied ? 42 : 0,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
setSelectedId: vi.fn(),
|
||||
|
|
@ -102,10 +106,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
|
|||
WoTable: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({
|
||||
WorkOrdersFilterBar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
|
||||
MediaUploadModal: () => null,
|
||||
}));
|
||||
|
|
@ -158,4 +158,113 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
);
|
||||
expect(clearAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies Severity and Has uplift through the public Advanced Filters sheet", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Severity/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /SEV 1/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Uplift/i }));
|
||||
fireEvent.click(within(sheet).getByLabelText(/Has uplift/i));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||
|
||||
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||
expect(applied.severities.has(1)).toBe(true);
|
||||
expect(applied.hasUplift).toBe(true);
|
||||
expect(applied.upliftStatuses.has("pending")).toBe(true);
|
||||
|
||||
const params = toBoardSearchQueryParams({ filters: applied });
|
||||
expect(params.page).toBe(0);
|
||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
expect(params.severities).toEqual([1]);
|
||||
expect(params.hasUplift).toBe(true);
|
||||
expect(params.upliftStatuses).toEqual(["pending"]);
|
||||
expect(params.datePreset).toBe("Custom");
|
||||
expect(params.dateFrom).toBe("2000-01-01");
|
||||
expect(params.dateTo).toBe("2099-12-31");
|
||||
});
|
||||
|
||||
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Indicators/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Work Order Number/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Internal numbers only/i }));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||
|
||||
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||
expect(applied.rescheduled).toBe(true);
|
||||
expect(applied.carriedOver).toBe(true);
|
||||
expect(applied.addOn).toBe(true);
|
||||
expect(applied.flagColors.has("#EF4444")).toBe(true);
|
||||
expect(applied.internalOnly).toBe(true);
|
||||
|
||||
const params = toBoardSearchQueryParams({ filters: applied });
|
||||
expect(params.page).toBe(0);
|
||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
expect(params.rescheduled).toBe(true);
|
||||
expect(params.carriedOver).toBe(true);
|
||||
expect(params.addOn).toBe(true);
|
||||
expect(params.flagColors).toEqual(["#EF4444"]);
|
||||
expect(params.internalOnly).toBe(true);
|
||||
expect(params.datePreset).toBe("Custom");
|
||||
expect(params.dateFrom).toBe("2000-01-01");
|
||||
expect(params.dateTo).toBe("2099-12-31");
|
||||
});
|
||||
|
||||
it("disables dispatcher, week, and type bar while advanced filters are applied", () => {
|
||||
advApplied = { ...EMPTY_ADVANCED_FILTERS, rescheduled: true };
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
|
||||
expect(screen.getByText("42 work orders")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
|
||||
expect(clearAdvancedFilters).toHaveBeenCalled();
|
||||
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
expect(screen.queryByText("42 work orders")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "All" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "PM" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue