mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 12:22:03 +00:00
feat(work-orders): add board advanced search UI (#42)
* feat(work-orders): add board advanced search UI * fix(work-orders): wire Advanced Search sheet and cover SH-121 flow * style(work-orders): format board fixture --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
parent
4337cc662b
commit
52e77dc197
8 changed files with 704 additions and 4 deletions
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { DotTogglePill } from "@/components/ui/dot-toggle-pill";
|
||||||
|
|
||||||
|
import { TogglePill } from "@/components/ui/toggle-pill";
|
||||||
|
|
||||||
|
import { ToggleSegment } from "@/components/ui/toggle-segment";
|
||||||
|
|
||||||
|
type AdvFilterPillProps = {
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
selected: boolean;
|
||||||
|
|
||||||
|
onClick: () => void;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvFilterPill({ label, selected, onClick, className }: AdvFilterPillProps) {
|
||||||
|
return (
|
||||||
|
<TogglePill selected={selected} onClick={onClick} className={className}>
|
||||||
|
{label}
|
||||||
|
</TogglePill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvStatusPillProps = {
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
dotColor: string;
|
||||||
|
|
||||||
|
selected: boolean;
|
||||||
|
|
||||||
|
selectedBg?: string;
|
||||||
|
|
||||||
|
selectedText?: string;
|
||||||
|
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvStatusPill({
|
||||||
|
label,
|
||||||
|
|
||||||
|
dotColor,
|
||||||
|
|
||||||
|
selected,
|
||||||
|
|
||||||
|
selectedBg,
|
||||||
|
|
||||||
|
selectedText,
|
||||||
|
|
||||||
|
onClick,
|
||||||
|
}: AdvStatusPillProps) {
|
||||||
|
return (
|
||||||
|
<DotTogglePill
|
||||||
|
label={label}
|
||||||
|
dotColor={dotColor}
|
||||||
|
selected={selected}
|
||||||
|
selectedBg={selectedBg}
|
||||||
|
selectedText={selectedText}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvDocPillProps = {
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
selected: boolean;
|
||||||
|
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvDocPill({ label, selected, onClick }: AdvDocPillProps) {
|
||||||
|
return (
|
||||||
|
<ToggleSegment selected={selected} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
</ToggleSegment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { CheckboxListItem } from "@/components/ui/checkbox-list-item";
|
||||||
|
|
||||||
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
|
|
||||||
|
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||||
|
|
||||||
|
type AdvMultiSelectOption = {
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdvMultiSelectProps = {
|
||||||
|
options: AdvMultiSelectOption[];
|
||||||
|
|
||||||
|
selected: Set<string>;
|
||||||
|
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
|
||||||
|
onBulkToggle?: (values: string[], select: boolean) => void;
|
||||||
|
|
||||||
|
placeholder: string;
|
||||||
|
|
||||||
|
renderLeft?: (value: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvMultiSelect({
|
||||||
|
options,
|
||||||
|
|
||||||
|
selected,
|
||||||
|
|
||||||
|
onToggle,
|
||||||
|
|
||||||
|
onBulkToggle,
|
||||||
|
|
||||||
|
placeholder,
|
||||||
|
|
||||||
|
renderLeft,
|
||||||
|
}: AdvMultiSelectProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const list = query
|
||||||
|
? options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const anySelected = selected.size > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SearchInput
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
placeholder={placeholder}
|
||||||
|
fullWidth
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{selected.size} of {options.length} selected
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectAllAction
|
||||||
|
anySelected={anySelected}
|
||||||
|
onClick={() =>
|
||||||
|
anySelected
|
||||||
|
? onBulkToggle?.(
|
||||||
|
options.map((option) => option.value),
|
||||||
|
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
: onBulkToggle?.(
|
||||||
|
list.map((option) => option.value),
|
||||||
|
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg border border-border p-1">
|
||||||
|
{list.map((option) => (
|
||||||
|
<CheckboxListItem
|
||||||
|
key={option.value}
|
||||||
|
selected={selected.has(option.value)}
|
||||||
|
label={option.label}
|
||||||
|
left={renderLeft?.(option.value)}
|
||||||
|
onClick={() => onToggle(option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{list.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-center text-xs text-muted-foreground">No matches</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { CollapsibleSection } from "@/components/ui/collapsible-section";
|
||||||
|
|
||||||
|
type AdvSectionProps = {
|
||||||
|
icon?: ElementType;
|
||||||
|
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
count?: number;
|
||||||
|
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvSection(props: AdvSectionProps) {
|
||||||
|
return <CollapsibleSection {...props} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||||
|
|
||||||
|
import { ToggleCard } from "@/components/ui/toggle-card";
|
||||||
|
|
||||||
|
import {
|
||||||
|
WO_TYPE_DESCRIPTIONS,
|
||||||
|
WO_TYPES,
|
||||||
|
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type AdvTypeGridProps = {
|
||||||
|
selected: Set<WOType>;
|
||||||
|
|
||||||
|
onToggle: (type: WOType) => void;
|
||||||
|
|
||||||
|
onSelectAll: (types: WOType[], select: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdvTypeGrid({ selected, onToggle, onSelectAll }: AdvTypeGridProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<SelectAllAction
|
||||||
|
anySelected={selected.size > 0}
|
||||||
|
onClick={() => onSelectAll([...WO_TYPES], selected.size === 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{WO_TYPES.map((type) => (
|
||||||
|
<ToggleCard
|
||||||
|
key={type}
|
||||||
|
selected={selected.has(type)}
|
||||||
|
title={type}
|
||||||
|
description={WO_TYPE_DESCRIPTIONS[type]}
|
||||||
|
onClick={() => onToggle(type)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Flame,
|
||||||
|
MapPin,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Tag,
|
||||||
|
Truck,
|
||||||
|
UserX,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill";
|
||||||
|
import { AdvMultiSelect } from "./adv-multi-select";
|
||||||
|
import { AdvSection } from "./adv-section";
|
||||||
|
import { AdvTypeGrid } from "./adv-type-grid";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DateField } from "@/components/ui/date-input";
|
||||||
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
|
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||||
|
import { SideSheet } from "@/components/ui/side-sheet";
|
||||||
|
import {
|
||||||
|
ALL_WIZARD_STATUSES,
|
||||||
|
ASSIGNEE_FILTER_UNASSIGNED,
|
||||||
|
COMPLETION_DOC_OPTIONS,
|
||||||
|
DATE_RANGE_OPTIONS,
|
||||||
|
DOC_FILTER_LABELS,
|
||||||
|
EMPTY_ADVANCED_FILTERS,
|
||||||
|
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 {
|
||||||
|
cloneAdvancedFilters,
|
||||||
|
countAdvancedFilters,
|
||||||
|
isDefaultDateRange,
|
||||||
|
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
|
||||||
|
type WorkOrdersAdvancedSearchSheetProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
initial: WorkOrderAdvancedFilters;
|
||||||
|
onApply: (filters: WorkOrderAdvancedFilters) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
sites: { id: string | number; name: string }[];
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
pmTypes: string[];
|
||||||
|
vendorTechs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrdersAdvancedSearchSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initial,
|
||||||
|
onApply,
|
||||||
|
onClearAll,
|
||||||
|
sites,
|
||||||
|
users,
|
||||||
|
pmTypes,
|
||||||
|
vendorTechs,
|
||||||
|
}: WorkOrdersAdvancedSearchSheetProps) {
|
||||||
|
const [draft, setDraft] = useState<WorkOrderAdvancedFilters>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(cloneAdvancedFilters(initial));
|
||||||
|
}
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const activeCount = countAdvancedFilters(draft);
|
||||||
|
|
||||||
|
const toggleIn = (key: AdvancedFilterSetKey, value: string) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const next = new Set(prev[key] as Set<string>);
|
||||||
|
if (next.has(value)) {
|
||||||
|
next.delete(value);
|
||||||
|
} else {
|
||||||
|
next.add(value);
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMany = (key: AdvancedFilterSetKey, values: string[], select: boolean) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const next = new Set(prev[key] as Set<string>);
|
||||||
|
values.forEach((value) => (select ? next.add(value) : next.delete(value)));
|
||||||
|
return { ...prev, [key]: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<SelectAllAction
|
||||||
|
anySelected={selectedSize > 0}
|
||||||
|
onClick={() => setMany(key, all, selectedSize === 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
|
||||||
|
onClearAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(cloneAdvancedFilters(draft));
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideSheet open={open} onClose={() => onOpenChange(false)} aria-label="Advanced Filters">
|
||||||
|
<span className="sr-only">Advanced Filters</span>
|
||||||
|
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal size={16} className="text-primary" />
|
||||||
|
<span className="font-display text-[15px] font-bold text-foreground">
|
||||||
|
Advanced Filters
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">· {activeCount} active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<IconCloseButton onClick={() => onOpenChange(false)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<AdvSection icon={MapPin} label="Site" count={draft.sites.size}>
|
||||||
|
<AdvMultiSelect
|
||||||
|
options={sites.map((site) => ({
|
||||||
|
value: String(site.id),
|
||||||
|
label: site.name,
|
||||||
|
}))}
|
||||||
|
selected={draft.sites}
|
||||||
|
onToggle={(value) => toggleIn("sites", value)}
|
||||||
|
onBulkToggle={(values, select) => setMany("sites", values, select)}
|
||||||
|
placeholder="Search site…"
|
||||||
|
/>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={Tag} label="Type of WO" count={draft.types.size}>
|
||||||
|
<AdvTypeGrid
|
||||||
|
selected={draft.types}
|
||||||
|
onToggle={(type) => toggleIn("types", type)}
|
||||||
|
onSelectAll={(types, select) => setMany("types", types, select)}
|
||||||
|
/>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={Users} label="Assigned to" count={draft.dispatchers.size}>
|
||||||
|
<AdvMultiSelect
|
||||||
|
options={[
|
||||||
|
{ value: ASSIGNEE_FILTER_UNASSIGNED, label: "Unassigned" },
|
||||||
|
...users.map((user) => ({ value: String(user.id), label: user.name })),
|
||||||
|
]}
|
||||||
|
selected={draft.dispatchers}
|
||||||
|
onToggle={(value) => toggleIn("dispatchers", value)}
|
||||||
|
onBulkToggle={(values, select) => setMany("dispatchers", values, select)}
|
||||||
|
placeholder="Search assigned to…"
|
||||||
|
renderLeft={(id) =>
|
||||||
|
id === ASSIGNEE_FILTER_UNASSIGNED ? (
|
||||||
|
<UserX size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="size-2 shrink-0 rounded-full bg-muted-foreground" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
||||||
|
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ALL_WIZARD_STATUSES.map((status) => (
|
||||||
|
<AdvStatusPill
|
||||||
|
key={status}
|
||||||
|
label={status}
|
||||||
|
dotColor={WIZARD_STATUS_DOTS[status]}
|
||||||
|
selected={draft.statuses.has(status)}
|
||||||
|
selectedBg="var(--color-primary-tint)"
|
||||||
|
onClick={() => toggleIn("statuses", status)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
||||||
|
<AdvMultiSelect
|
||||||
|
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||||
|
selected={draft.pmTypes}
|
||||||
|
onToggle={(value) => toggleIn("pmTypes", value)}
|
||||||
|
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
|
||||||
|
placeholder="Search pm type…"
|
||||||
|
/>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={Truck} label="Vendor / tech" count={draft.vendorTechs.size}>
|
||||||
|
<AdvMultiSelect
|
||||||
|
options={vendorTechs.map((name) => ({ value: name, label: name }))}
|
||||||
|
selected={draft.vendorTechs}
|
||||||
|
onToggle={(value) => toggleIn("vendorTechs", value)}
|
||||||
|
onBulkToggle={(values, select) => setMany("vendorTechs", values, select)}
|
||||||
|
placeholder="Search vendor / tech…"
|
||||||
|
/>
|
||||||
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
|
||||||
|
{selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COMPLETION_DOC_OPTIONS.map((doc) => (
|
||||||
|
<AdvDocPill
|
||||||
|
key={doc}
|
||||||
|
label={DOC_FILTER_LABELS[doc]}
|
||||||
|
selected={draft.docs.has(doc)}
|
||||||
|
onClick={() => toggleIn("docs", doc)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AdvSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button variant="ghost" className="text-xs" onClick={handleClearAll}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-center">
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{activeCount} filter set
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button className="px-5 text-xs" onClick={handleApply}>
|
||||||
|
Apply filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { Box } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
|
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||||
|
|
@ -44,6 +45,11 @@ export default function WorkOrdersListPage() {
|
||||||
[problemOptions],
|
[problemOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const vendorTechs = useMemo(
|
||||||
|
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
||||||
|
[vendors],
|
||||||
|
);
|
||||||
|
|
||||||
const totalCount = tableData.totalCount;
|
const totalCount = tableData.totalCount;
|
||||||
const scopeTotalCount = tableData.scopeTotalCount;
|
const scopeTotalCount = tableData.scopeTotalCount;
|
||||||
|
|
||||||
|
|
@ -62,6 +68,7 @@ export default function WorkOrdersListPage() {
|
||||||
<WorkOrdersListHeader
|
<WorkOrdersListHeader
|
||||||
search={filters.search}
|
search={filters.search}
|
||||||
onSearchChange={filters.handleSearchChange}
|
onSearchChange={filters.handleSearchChange}
|
||||||
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
activeFilterCount={filters.activeFilterCount}
|
activeFilterCount={filters.activeFilterCount}
|
||||||
isFetching={tableData.isFetching && !tableData.isLoading}
|
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
@ -104,6 +111,19 @@ export default function WorkOrdersListPage() {
|
||||||
}}
|
}}
|
||||||
onOpenMedia={setMediaRow}
|
onOpenMedia={setMediaRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrdersAdvancedSearchSheet
|
||||||
|
open={filters.advOpen}
|
||||||
|
onOpenChange={filters.setAdvOpen}
|
||||||
|
initial={filters.advInitial}
|
||||||
|
onApply={filters.applyAdvancedFilters}
|
||||||
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
|
sites={locations}
|
||||||
|
users={users}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
vendorTechs={vendorTechs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeMediaRow != null && (
|
{activeMediaRow != null && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
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 { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const setAdvOpen = vi.fn();
|
||||||
|
const applyAdvancedFilters = vi.fn();
|
||||||
|
const clearAdvancedFilters = vi.fn();
|
||||||
|
let advOpen = false;
|
||||||
|
let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null;
|
||||||
|
|
||||||
|
vi.mock("react-router", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-router")>("react-router");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", () => ({
|
||||||
|
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
|
useWorkOrdersListFilters: () => ({
|
||||||
|
search: "",
|
||||||
|
handleSearchChange: vi.fn(),
|
||||||
|
advOpen,
|
||||||
|
setAdvOpen: (open: boolean) => {
|
||||||
|
advOpen = open;
|
||||||
|
setAdvOpen(open);
|
||||||
|
},
|
||||||
|
advApplied,
|
||||||
|
advInitial: advApplied ?? EMPTY_ADVANCED_FILTERS,
|
||||||
|
applyAdvancedFilters: (filters: typeof EMPTY_ADVANCED_FILTERS) => {
|
||||||
|
advApplied = filters;
|
||||||
|
applyAdvancedFilters(filters);
|
||||||
|
},
|
||||||
|
clearAdvancedFilters: () => {
|
||||||
|
advApplied = null;
|
||||||
|
clearAdvancedFilters();
|
||||||
|
},
|
||||||
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
|
activeFilterCount: advApplied ? 1 : 0,
|
||||||
|
weekMonday: "2026-07-20",
|
||||||
|
setWeekMonday: vi.fn(),
|
||||||
|
dispatcherIds: new Set<string>(),
|
||||||
|
setDispatcherIds: vi.fn(),
|
||||||
|
typeFilter: "All",
|
||||||
|
setTypeFilter: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||||
|
useWorkOrdersTableData: () => ({
|
||||||
|
rows: [],
|
||||||
|
totalCount: 0,
|
||||||
|
scopeTotalCount: 0,
|
||||||
|
isFetching: false,
|
||||||
|
isLoading: false,
|
||||||
|
setSelectedId: vi.fn(),
|
||||||
|
patchRow: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||||
|
useWorkOrderTableMutations: () => ({ patchField: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||||
|
useWorkOrderUsers: () => ({
|
||||||
|
data: [{ id: "u1", name: "Dispatcher One" }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderLocations: () => ({
|
||||||
|
data: [{ id: "loc-1", name: "Seattle" }],
|
||||||
|
}),
|
||||||
|
useWorkOrderVendors: () => ({
|
||||||
|
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||||
|
useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
|
||||||
|
isWoTableMockEnabled: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
advOpen = false;
|
||||||
|
advApplied = null;
|
||||||
|
setAdvOpen.mockClear();
|
||||||
|
applyAdvancedFilters.mockClear();
|
||||||
|
clearAdvancedFilters.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the sheet from Advanced Filters, applies, then clears", () => {
|
||||||
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
|
route: "/workorders",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /apply filters/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||||
|
expect(setAdvOpen).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand filter sections that start collapsed and assert option inputs are wired.
|
||||||
|
for (const label of ["Site", "Assigned to", "PM type", "Vendor / tech"]) {
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") }));
|
||||||
|
}
|
||||||
|
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||||
|
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||||
|
expect(setAdvOpen).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByRole("dialog", { name: "Advanced Filters" })).getByRole("button", {
|
||||||
|
name: /clear all/i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(clearAdvancedFilters).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,10 +25,24 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
||||||
search: "",
|
search: "",
|
||||||
handleSearchChange: vi.fn(),
|
handleSearchChange: vi.fn(),
|
||||||
setAdvOpen: vi.fn(),
|
setAdvOpen: vi.fn(),
|
||||||
activeFilterCount: 0,
|
advOpen: false,
|
||||||
advApplied: null,
|
advApplied: null,
|
||||||
clearAdvancedFilterChip: vi.fn(),
|
advInitial: {
|
||||||
|
dateRange: "this-week",
|
||||||
|
customFrom: "",
|
||||||
|
customTo: "",
|
||||||
|
sites: new Set(),
|
||||||
|
types: new Set(),
|
||||||
|
dispatchers: new Set(),
|
||||||
|
statuses: new Set(),
|
||||||
|
pmTypes: new Set(),
|
||||||
|
vendorTechs: new Set(),
|
||||||
|
docs: new Set(),
|
||||||
|
},
|
||||||
|
applyAdvancedFilters: vi.fn(),
|
||||||
clearAdvancedFilters: vi.fn(),
|
clearAdvancedFilters: vi.fn(),
|
||||||
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
|
activeFilterCount: 0,
|
||||||
weekMonday: "2026-07-20",
|
weekMonday: "2026-07-20",
|
||||||
setWeekMonday: vi.fn(),
|
setWeekMonday: vi.fn(),
|
||||||
dispatcherIds: new Set<string>(),
|
dispatcherIds: new Set<string>(),
|
||||||
|
|
@ -165,13 +179,12 @@ describe("WorkOrdersListHeader slice affordances", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkOrdersListPage board-core public interface", () => {
|
describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
it("does not render New WO or Advanced Filters, and View navigates to detail", () => {
|
it("does not render New WO, and View navigates to detail", () => {
|
||||||
navigate.mockReset();
|
navigate.mockReset();
|
||||||
|
|
||||||
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue