Compare commits

...

5 commits

Author SHA1 Message Date
Codex Review Integration
83df262ab6 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory 2026-09-15 19:12:21 -03:00
Arthur Bassi
fc3e7591dc
fix(work-orders): add Unscheduled option to Advanced Filters Date range (#181)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
* fix(work-orders): add Unscheduled option to Advanced Filters Date range

* fix(work-orders): page unscheduled results server-side

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-09-15 19:11:35 -03:00
Codex Review Integration
b83514a240 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory
# Conflicts:
#	e2e/vendors/vendors.spec.ts
#	src/app/(protected)/vendors/_components/vendors-table.tsx
#	src/test/app/(protected)/vendors/vendors-list.test.tsx
2026-09-15 19:08:53 -03:00
Alexandre Brandizzi
ebc401dcb7
SH-273: derive Site Area from State (#190)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
* feat(locations): derive site areas from state

* chore(locations): keep ticket keys out of source comments

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:38:18 -03:00
Alexandre Brandizzi
861f820715
SH-279: replace Vendor trade overflow title with MUI Tooltip (#187)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
* fix(vendors): add accessible trade overflow tooltip

* refactor(vendors): satisfy row maintainability gate

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:23:14 -03:00
32 changed files with 1789 additions and 299 deletions

View file

@ -0,0 +1,22 @@
import { Autocomplete, TextField } from "@mui/material";
import { AREA_ZONES, type AreaZone } from "@/data/areas";
interface AreaFacetProps {
value: AreaZone[];
onChange: (zones: AreaZone[]) => void;
}
/** Multi-select area facet; an empty selection means no area filtering. */
export function AreaFacet({ value, onChange }: AreaFacetProps) {
return (
<Autocomplete<AreaZone, true, false, false>
multiple
size="small"
className="min-w-[200px]"
options={AREA_ZONES}
value={value}
onChange={(_event, zones) => onChange(zones)}
renderInput={(params) => <TextField {...params} label="Area" placeholder="All areas" />}
/>
);
}

View file

@ -0,0 +1,42 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
interface LocationDeleteDialogProps {
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
target: LocationListItem | null;
}
export function LocationDeleteDialog({
isPending,
onClose,
onConfirm,
target,
}: LocationDeleteDialogProps) {
return (
<Dialog open={Boolean(target)} onClose={onClose}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,159 @@
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
import {
Autocomplete,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { StateSelect } from "./state-select";
interface SharedFieldsProps {
control: Control<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
interface LocationDetailsFieldsProps extends SharedFieldsProps {
accountOptions: AccountListItem[];
isLoadingAccounts: boolean;
onAccountSearchChange: (value: string) => void;
}
export function LocationDetailsFields({
accountOptions,
control,
errors,
isLoadingAccounts,
onAccountSearchChange,
register,
}: LocationDetailsFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
onAccountSearchChange(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
);
}
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("phone")}
label="Phone"
required
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
<TextField
{...register("contact")}
label="Contact Person"
required
error={Boolean(errors.contact)}
helperText={errors.contact?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
);
}

View file

@ -1,34 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { normalizeStateCode } from "@/data/areas";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { useDebounce } from "@/hooks/use-debounce";
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
export default function LocationFormPage() {
const { id } = useParams();
@ -67,7 +53,7 @@ export default function LocationFormPage() {
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: (location.state as LocationFormValues["state"]) || "IL",
state: normalizeStateCode(location.state) ?? "",
zipCode: location.zipCode ?? "",
phone: location.phone ?? "",
contact: location.contact ?? "",
@ -121,141 +107,15 @@ export default function LocationFormPage() {
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) =>
String(option.id) === String(value.id)
}
value={
accountOptions.find((account) => String(account.id) === field.value) ?? null
}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setAccountSearch(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<Controller
name="state"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.state)}>
<InputLabel id="state-label">State</InputLabel>
<Select {...field} labelId="state-label" label="State" value={field.value}>
{LOCATION_STATE_OPTIONS.map((state) => (
<MenuItem key={state} value={state}>
{state}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("phone")}
label="Phone"
required
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
<TextField
{...register("contact")}
label="Contact Person"
required
error={Boolean(errors.contact)}
helperText={errors.contact?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
<LocationDetailsFields
accountOptions={accountOptions}
control={control}
errors={errors}
isLoadingAccounts={isLoadingAccounts}
onAccountSearchChange={setAccountSearch}
register={register}
/>
<LocationContactFields control={control} errors={errors} register={register} />
<Stack
direction="row"

View file

@ -0,0 +1,102 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onEdit: (row: LocationListItem) => void;
rows: LocationListItem[];
}
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onEdit(row)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>
<StateAreaCell state={row.state} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.contact || "—"}</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => onDelete(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,35 @@
import { Stack, Typography } from "@mui/material";
import { getAreaForState, normalizeStateCode } from "@/data/areas";
/** State abbreviation with the derived area as secondary text. */
export function StateAreaCell({ state }: { state?: string | null }) {
const code = normalizeStateCode(state);
const area = code === null ? null : getAreaForState(code);
if (code === null) {
return (
<Typography variant="body2" component="span">
{state?.trim() || "—"}
</Typography>
);
}
return (
<Stack spacing={0}>
<Typography variant="body2" component="span">
{code}
</Typography>
{area !== null && (
<Typography
variant="caption"
component="span"
sx={{
color: "text.secondary",
}}
>
{area}
</Typography>
)}
</Stack>
);
}

View file

@ -0,0 +1,49 @@
import { Controller, type Control, type FieldError } from "react-hook-form";
import { Autocomplete, TextField } from "@mui/material";
import {
US_STATE_CODES,
formatStateOptionLabel,
getAreaForState,
type StateCode,
} from "@/data/areas";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
interface StateSelectProps {
control: Control<LocationFormValues>;
error?: FieldError;
}
/** Searchable, non-free-text state picker with derived area helper text. */
export function StateSelect({ control, error }: StateSelectProps) {
return (
<Controller
name="state"
control={control}
render={({ field }) => {
const selected: StateCode | null =
US_STATE_CODES.find((code) => code === field.value) ?? null;
const area = selected === null ? null : getAreaForState(selected);
const helperText = error?.message ?? (area === null ? undefined : `Area: ${area}`);
return (
<Autocomplete<StateCode>
options={US_STATE_CODES}
value={selected}
onChange={(_event, option) => field.onChange(option ?? "")}
onBlur={field.onBlur}
getOptionLabel={formatStateOptionLabel}
renderInput={(params) => (
<TextField
{...params}
label="State"
required
error={Boolean(error)}
helperText={helperText}
/>
)}
/>
);
}}
/>
);
}

View file

@ -1,53 +1,45 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
import { useDebounce } from "@/hooks/use-debounce";
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
import { AreaFacet } from "./_components/area-facet";
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
import { LocationsTable } from "./_components/locations-table";
export default function LocationsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteLocation = useDeleteLocation();
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
states: stateCodes.length > 0 ? stateCodes : undefined,
}),
[debouncedSearch, page, pageSize],
[debouncedSearch, page, pageSize, stateCodes],
);
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
@ -103,6 +95,13 @@ export default function LocationsListPage() {
alignItems: "center",
}}
>
<AreaFacet
value={selectedAreas}
onChange={(zones) => {
setSelectedAreas(zones);
setPage(0);
}}
/>
<TextField
size="small"
value={search}
@ -123,86 +122,12 @@ export default function LocationsListPage() {
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No locations found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>{row.state || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.contact || "—"}</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit location"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<LocationsTable
isLoading={isLoading}
rows={rows}
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
onDelete={setDeleteTarget}
/>
<TablePagination
component="div"
count={totalCount}
@ -215,28 +140,12 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteLocation.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteLocation.isPending}
>
{deleteLocation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
</Box>
);
}

View file

@ -14,13 +14,13 @@ type AdvDateRangeSectionProps = {
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
};
function dateRangeSectionCount(draft: WorkOrderAdvancedFilters): number {
return (isDefaultDateRange(draft.dateRange) ? 0 : 1) + (draft.unscheduledOnly ? 1 : 0);
}
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
return (
<AdvSection
icon={CalendarDays}
label="Date range"
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
>
<AdvSection icon={CalendarDays} label="Date range" count={dateRangeSectionCount(draft)}>
<div className="flex flex-wrap gap-1.5">
{DATE_RANGE_OPTIONS.map((option) => (
<AdvFilterPill
@ -30,6 +30,11 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
<AdvFilterPill
label="Unscheduled"
selected={draft.unscheduledOnly}
onClick={() => setDraft((prev) => ({ ...prev, unscheduledOnly: !prev.unscheduledOnly }))}
/>
</div>
{draft.dateRange === "custom" && (
<div className="mt-2.5 flex items-center gap-2">

View file

@ -32,7 +32,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
});
const { displayRows, ...localUi } = local;
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
// SH-338: the server owns rows/totals for advanced search (incl. Unscheduled).
const advResults = useMemo(
() => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS),
[advActive, displayRows],
);
const filtered = useMemo(() => {
if (advActive) {

170
src/data/areas.ts Normal file
View file

@ -0,0 +1,170 @@
/**
* Canonical source of truth for US states and their Seahaven area zone
* mapping. Exactly the 50 states — no DC and no territories.
* Area is always derived from the state code and never persisted.
*/
export const AREA_ZONES = ["East", "Central", "West", "California"] as const;
export type AreaZone = (typeof AREA_ZONES)[number];
export const AREA_ZONE_STATE_CODES = {
East: [
"CT",
"DE",
"GA",
"MA",
"MD",
"ME",
"NC",
"NH",
"NJ",
"NY",
"PA",
"RI",
"SC",
"VA",
"VT",
"WV",
],
Central: [
"AL",
"AR",
"FL",
"IA",
"IL",
"IN",
"KS",
"KY",
"LA",
"MI",
"MN",
"MO",
"MS",
"ND",
"NE",
"OH",
"OK",
"SD",
"TN",
"WI",
],
West: ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"],
California: ["CA"],
} as const satisfies Record<AreaZone, readonly string[]>;
export type StateCode = (typeof AREA_ZONE_STATE_CODES)[AreaZone][number];
/** All 50 state codes in canonical order: East, Central, West, California. */
export const US_STATE_CODES = [
...AREA_ZONE_STATE_CODES.East,
...AREA_ZONE_STATE_CODES.Central,
...AREA_ZONE_STATE_CODES.West,
...AREA_ZONE_STATE_CODES.California,
] as [StateCode, ...StateCode[]];
const STATE_NAMES = {
AL: "Alabama",
AK: "Alaska",
AZ: "Arizona",
AR: "Arkansas",
CA: "California",
CO: "Colorado",
CT: "Connecticut",
DE: "Delaware",
FL: "Florida",
GA: "Georgia",
HI: "Hawaii",
ID: "Idaho",
IL: "Illinois",
IN: "Indiana",
IA: "Iowa",
KS: "Kansas",
KY: "Kentucky",
LA: "Louisiana",
ME: "Maine",
MD: "Maryland",
MA: "Massachusetts",
MI: "Michigan",
MN: "Minnesota",
MS: "Mississippi",
MO: "Missouri",
MT: "Montana",
NE: "Nebraska",
NV: "Nevada",
NH: "New Hampshire",
NJ: "New Jersey",
NM: "New Mexico",
NY: "New York",
NC: "North Carolina",
ND: "North Dakota",
OH: "Ohio",
OK: "Oklahoma",
OR: "Oregon",
PA: "Pennsylvania",
RI: "Rhode Island",
SC: "South Carolina",
SD: "South Dakota",
TN: "Tennessee",
TX: "Texas",
UT: "Utah",
VT: "Vermont",
VA: "Virginia",
WA: "Washington",
WV: "West Virginia",
WI: "Wisconsin",
WY: "Wyoming",
} as const satisfies Record<StateCode, string>;
export interface StateAreaEntry {
code: StateCode;
name: string;
zone: AreaZone;
}
/** All 50 states with postal code, full name, and their single area zone. */
export const STATE_AREA_ENTRIES: readonly StateAreaEntry[] = AREA_ZONES.flatMap((zone) =>
AREA_ZONE_STATE_CODES[zone].map((code) => ({ code, name: STATE_NAMES[code], zone })),
);
const STATE_CODE_SET = new Set<string>(US_STATE_CODES);
const STATE_NAME_TO_CODE = new Map<string, StateCode>(
STATE_AREA_ENTRIES.map((entry) => [entry.name.toLowerCase(), entry.code]),
);
const STATE_ZONE_BY_CODE = Object.fromEntries(
STATE_AREA_ENTRIES.map((entry) => [entry.code, entry.zone]),
) as Record<StateCode, AreaZone>;
/** Autocomplete option label: `Texas (TX)`. */
export function formatStateOptionLabel(code: StateCode): string {
return `${STATE_NAMES[code]} (${code})`;
}
/**
* Normalizes legacy API state values to the canonical two-letter code.
* Accepts codes in any case and full state names (any case); otherwise null.
*/
export function normalizeStateCode(value: string | null | undefined): StateCode | null {
const trimmed = value?.trim() ?? "";
if (trimmed === "") {
return null;
}
const upper = trimmed.toUpperCase();
if (STATE_CODE_SET.has(upper)) {
return upper as StateCode;
}
return STATE_NAME_TO_CODE.get(trimmed.toLowerCase()) ?? null;
}
/** Derives the area zone for a state code or legacy full-name value. */
export function getAreaForState(value: string | null | undefined): AreaZone | null {
const code = normalizeStateCode(value);
return code === null ? null : STATE_ZONE_BY_CODE[code];
}
/** Expands selected area zones to their state codes in canonical order. */
export function expandAreasToStateCodes(areas: readonly AreaZone[]): StateCode[] {
const selected = new Set<AreaZone>(areas);
return US_STATE_CODES.filter((code) => selected.has(STATE_ZONE_BY_CODE[code]));
}

View file

@ -15,11 +15,17 @@ import type {
} from "@/domain/locations/types/location";
function buildSearchParams(params: LocationsListParams): Record<string, string | number> {
return {
const searchParams: Record<string, string | number> = {
search: params.search ?? "",
page: params.page ?? 1,
pageSize: params.pageSize ?? 12,
};
if (params.states && params.states.length > 0) {
searchParams.states = params.states.join(",");
}
return searchParams;
}
export const locationsApi = {

View file

@ -1,12 +1,15 @@
import { z } from "zod";
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { US_STATE_CODES } from "@/data/areas";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
export const locationSchema = z.object({
name: z.string().min(1, "Name is required"),
accountId: z.string().optional(),
address: z.string().min(1, "Address is required"),
city: z.string().min(1, "City is required"),
state: z.enum(LOCATION_STATE_OPTIONS, { message: "State is required" }),
state: z
.union([z.enum(US_STATE_CODES), z.literal("")])
.refine((state): boolean => state !== "", { message: "State is required" }),
zipCode: z.string().min(1, "Zip code is required"),
phone: z.string().min(1, "Phone is required"),
contact: z.string().min(1, "Contact person is required"),
@ -21,7 +24,7 @@ export const locationDefaultValues: LocationFormValues = {
accountId: "",
address: "",
city: "",
state: "IL",
state: "",
zipCode: "",
phone: "",
contact: "",

View file

@ -1,3 +1,5 @@
import type { StateCode } from "@/data/areas";
export interface LocationListItem {
id: string | number;
name: string;
@ -38,6 +40,8 @@ export interface LocationsListParams {
search?: string;
page?: number;
pageSize?: number;
/** State codes to filter by (area expansion); omitted from the query when empty. */
states?: StateCode[];
}
export interface LocationsListResult {
@ -47,6 +51,4 @@ export interface LocationsListResult {
pageSize: number;
}
export const LOCATION_STATE_OPTIONS = ["IL", "IN", "MI", "OH", "WI"] as const;
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;

View file

@ -26,6 +26,8 @@ export interface WorkOrderAdvancedFilters {
dateRange: DateRangeKey;
customFrom: string;
customTo: string;
/** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */
unscheduledOnly: boolean;
sites: Set<string>;
types: Set<WOType>;
severities: Set<WorkOrderSeverity>;
@ -48,6 +50,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dateRange: "this-week",
customFrom: "",
customTo: "",
unscheduledOnly: false,
sites: new Set(),
types: new Set(),
severities: new Set(),

View file

@ -299,6 +299,12 @@ export interface WorkOrderBoardSearchQueryParams {
avetaOnly?: boolean;
flagColors?: string[];
internalOnly?: boolean;
/**
* SH-338: server-side Unscheduled facet. `true` makes the server ignore the
* date range only; every other facet still applies. Absent/false keeps the
* legacy wire behavior.
*/
unscheduledOnly?: boolean;
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;

View file

@ -60,21 +60,29 @@ function severityLabel(level: WorkOrderSeverity): string {
}
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");
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: "",
}),
});
}
if (!filters.unscheduledOnly) return;
chips.push({
key: "date",
key: "unscheduled",
group: "Date",
value,
clear: (current) => ({
...current,
dateRange: "this-week",
customFrom: "",
customTo: "",
}),
value: "Unscheduled",
clear: (current) => ({ ...current, unscheduledOnly: false }),
});
}

View file

@ -9,6 +9,9 @@ type AdvancedFilterPredicate = (
) => boolean;
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
if (filters.unscheduledOnly) {
return !row.scheduledOn;
}
const [from, to] = dateRangeFor(filters);
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
};

View file

@ -32,6 +32,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
docs: new Set(filters.docs),
flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly),
};
}
@ -52,6 +53,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
Boolean(filters.flagColors?.size),
filters.internalOnly,
filters.hasUplift,
filters.unscheduledOnly,
];
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {

View file

@ -155,10 +155,15 @@ export function toBoardQueryParams(
}
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
datePreset: WorkOrderBoardSearchQueryParams["datePreset"];
datePreset?: WorkOrderBoardSearchQueryParams["datePreset"];
dateFrom?: string;
dateTo?: string;
} {
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
// client must not fake a Custom window and sends no date params.
if (filters.unscheduledOnly) {
return {};
}
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
return {
datePreset: "Custom",
@ -216,6 +221,7 @@ export function toBoardSearchQueryParams(input: {
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
docStatuses: mapDocStatuses(filters.docs),
unscheduledOnly: filters.unscheduledOnly || undefined,
...mapSearchFacetParams(filters),
sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc",

View file

@ -0,0 +1,103 @@
import { screen } from "@testing-library/react";
import { Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import LocationFormPage from "@/app/(protected)/locations/_components/location-form";
import { renderWithProviders } from "@/test/test-utils";
const useLocationDetail = vi.fn();
const useAccountsList = vi.fn();
const useCreateLocation = vi.fn();
const useUpdateLocation = vi.fn();
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: (...args: unknown[]) => useLocationDetail(...args),
}));
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
useAccountsList: (...args: unknown[]) => useAccountsList(...args),
}));
vi.mock("@/domain/locations/use-cases/use-create-location", () => ({
useCreateLocation: (...args: unknown[]) => useCreateLocation(...args),
}));
vi.mock("@/domain/locations/use-cases/use-update-location", () => ({
useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args),
}));
function mockHooks(location: Record<string, unknown> | null) {
useLocationDetail.mockReturnValue({
data: location,
isLoading: false,
error: null,
});
useAccountsList.mockReturnValue({
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
isLoading: false,
});
useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
}
function renderEditPage(locationId: string) {
return renderWithProviders(
<Routes>
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
</Routes>,
{
route: `/locations/edit/${locationId}`,
routerProps: { initialEntries: [`/locations/edit/${locationId}`] },
withAuth: false,
},
);
}
describe("LocationFormPage state field", () => {
it("normalizes legacy API state values for edit", () => {
mockHooks({
id: "1",
name: "Dallas DC",
address: "3811 Distribution Dr",
city: "Dallas",
state: "texas",
zipCode: "70068",
phone: "(421) 433-0032",
contact: "Jane",
contactEmail: "jane@example.com",
status: "Active",
accountId: "7",
});
renderEditPage("1");
const stateInput = screen.getByLabelText(/^State/);
expect(stateInput).toHaveValue("Texas (TX)");
expect(screen.getByText("Area: West")).toBeInTheDocument();
});
it("normalizes code values and keeps their derived area", () => {
mockHooks({
id: "2",
state: "IL",
status: "Active",
});
renderEditPage("2");
expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)");
expect(screen.getByText("Area: Central")).toBeInTheDocument();
});
it("starts empty in create mode with no implicit IL default", () => {
mockHooks(null);
renderWithProviders(<LocationFormPage />, {
route: "/locations/new",
routerProps: { initialEntries: ["/locations/new"] },
withAuth: false,
});
expect(screen.getByLabelText(/^State/)).toHaveValue("");
expect(screen.queryByText(/Area: /)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,139 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import LocationsListPage from "@/app/(protected)/locations/index";
import { expandAreasToStateCodes } from "@/data/areas";
import { renderWithProviders } from "@/test/test-utils";
const useLocationsList = vi.fn();
const useDeleteLocation = vi.fn();
vi.mock("@/domain/locations/use-cases/use-locations-list", () => ({
useLocationsList: (...args: unknown[]) => useLocationsList(...args),
}));
vi.mock("@/domain/locations/use-cases/use-delete-location", () => ({
useDeleteLocation: (...args: unknown[]) => useDeleteLocation(...args),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const rows = [
{
id: 1,
name: "Dallas DC",
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",
zipCode: "70068",
phone: "(421) 433-0032",
contact: "Jane",
contactEmail: "jane@example.com",
status: "Active",
accountId: 7,
},
{
id: 2,
name: "Chicago HQ",
address: "100 Main St",
city: "Chicago",
state: "Illinois",
zipCode: "60601",
phone: "(312) 555-0100",
contact: "Bob",
contactEmail: "bob@example.com",
status: "Active",
accountId: 8,
},
];
function lastListParams() {
const calls = useLocationsList.mock.calls;
return calls[calls.length - 1][0] as Record<string, unknown>;
}
function renderListPage() {
useDeleteLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
useLocationsList.mockReturnValue({
data: { items: rows, totalCount: 30, page: 1, pageSize: 12 },
isLoading: false,
isFetching: false,
error: null,
});
return renderWithProviders(<LocationsListPage />, { route: "/locations", withAuth: false });
}
describe("LocationsListPage area wiring", () => {
it("shows the state abbreviation with the area as secondary text", () => {
renderListPage();
const table = screen.getByRole("table");
expect(within(table).getByText("TX")).toBeInTheDocument();
expect(within(table).getByText("West")).toBeInTheDocument();
expect(within(table).getByText("IL")).toBeInTheDocument();
expect(within(table).getByText("Central")).toBeInTheDocument();
expect(within(table).queryByText("Illinois")).not.toBeInTheDocument();
});
it("sends no states filter while the area facet is empty", () => {
renderListPage();
expect(lastListParams()).toEqual({ search: "", page: 1, pageSize: 12 });
});
it("offers the four zones in canonical order", () => {
renderListPage();
fireEvent.mouseDown(screen.getByLabelText("Area"));
const options = screen.getAllByRole("option").map((option) => option.textContent ?? "");
expect(options).toEqual(["East", "Central", "West", "California"]);
});
it("expands selected areas to state codes and resets the page", () => {
renderListPage();
fireEvent.click(screen.getByRole("button", { name: "Go to next page" }));
expect(lastListParams()).toMatchObject({ page: 2 });
fireEvent.mouseDown(screen.getByLabelText("Area"));
fireEvent.click(screen.getByRole("option", { name: "East" }));
expect(lastListParams()).toEqual({
search: "",
page: 1,
pageSize: 12,
states: expandAreasToStateCodes(["East"]),
});
expect(lastListParams().states).toHaveLength(16);
fireEvent.mouseDown(screen.getByLabelText("Area"));
fireEvent.click(screen.getByRole("option", { name: "California" }));
expect(lastListParams()).toEqual({
search: "",
page: 1,
pageSize: 12,
states: expandAreasToStateCodes(["East", "California"]),
});
});
it("preserves search filtering alongside the area facet", () => {
renderListPage();
fireEvent.mouseDown(screen.getByLabelText("Area"));
fireEvent.click(screen.getByRole("option", { name: "West" }));
fireEvent.change(screen.getByPlaceholderText("Search locations..."), {
target: { value: "dal" },
});
expect(lastListParams()).toEqual({
search: "dal",
page: 1,
pageSize: 12,
states: expandAreasToStateCodes(["West"]),
});
});
});

View file

@ -0,0 +1,137 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { StateSelect } from "@/app/(protected)/locations/_components/state-select";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { renderWithProviders } from "@/test/test-utils";
function StateSelectHarness({
onSubmit = vi.fn(),
}: {
onSubmit?: (values: LocationFormValues) => void;
}) {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: {
...locationDefaultValues,
name: "Dallas DC",
address: "3811 Distribution Dr",
city: "Dallas",
zipCode: "70068",
phone: "(421) 433-0032",
contact: "Jane",
contactEmail: "jane@example.com",
status: "Active",
},
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<StateSelect control={control} error={errors.state} />
<button type="submit">Save</button>
</form>
);
}
function openOptions() {
const input = screen.getByRole("combobox");
input.focus();
fireEvent.mouseDown(input);
return input;
}
describe("StateSelect", () => {
it("starts empty with no implicit default state", () => {
renderWithProviders(<StateSelectHarness />, { withAuth: false });
expect(screen.getByRole("combobox")).toHaveValue("");
expect(screen.queryByText("Area: ")).not.toBeInTheDocument();
});
it("shows searchable Name (CODE) options and no free-text entries", async () => {
renderWithProviders(<StateSelectHarness />, { withAuth: false });
const input = openOptions();
const options = await screen.findAllByRole("option");
expect(options).toHaveLength(50);
expect(screen.getByRole("option", { name: "Texas (TX)" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "California (CA)" })).toBeInTheDocument();
fireEvent.change(input, { target: { value: "mass" } });
await screen.findAllByRole("option");
expect(screen.getByRole("option", { name: "Massachusetts (MA)" })).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(1);
fireEvent.change(input, { target: { value: "Republic of Texas" } });
expect(screen.queryByRole("option")).not.toBeInTheDocument();
});
it("selects by search and shows the Area helper immediately", async () => {
const onSubmit = vi.fn();
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
const input = openOptions();
fireEvent.change(input, { target: { value: "texas" } });
fireEvent.click(await screen.findByRole("option", { name: "Texas (TX)" }));
expect(input).toHaveValue("Texas (TX)");
expect(screen.getByText("Area: West")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ state: "TX" }),
expect.anything(),
);
});
});
it("is keyboard accessible (arrow + enter selection)", async () => {
const onSubmit = vi.fn();
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
const input = screen.getByRole("combobox");
input.focus();
fireEvent.change(input, { target: { value: "ohio" } });
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("Ohio (OH)");
expect(screen.getByText("Area: Central")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ state: "OH" }),
expect.anything(),
);
});
});
it("requires a state and rejects free text on submit", async () => {
const onSubmit = vi.fn();
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("State is required")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
const input = screen.getByRole("combobox");
input.focus();
fireEvent.change(input, { target: { value: "NotAState" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("State is required");
expect(onSubmit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,233 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
const { searchBoard, getBoard, emptyList } = vi.hoisted(() => ({
searchBoard: vi.fn(),
getBoard: vi.fn(),
emptyList: [] as const,
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
searchBoard: (...args: unknown[]) => searchBoard(...args),
getBoard: (...args: unknown[]) => getBoard(...args),
},
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: emptyList }),
useWorkOrderVendors: () => ({ data: emptyList }),
}));
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
useLocationSites: () => ({ data: emptyList }),
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
const PAGE_SIZE = 25;
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "WO-1",
site: "CLV2",
type: "PM",
severity: null,
dispatcherId: "",
dispatcherName: "",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Cleveland, OH",
locationId: "",
pm: "Backflow",
serviceNotes: "",
extraServices: [],
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "—",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
vendorId: "",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
additionalContacts: [],
...overrides,
};
}
function emptyBoardResult() {
return {
items: [] as WorkOrderTableRow[],
scheduled: [] as WorkOrderTableRow[],
unscheduled: [] as WorkOrderTableRow[],
totalCount: 0,
returnedCount: 0,
page: 0,
pageSize: PAGE_SIZE,
};
}
function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) {
const items = rows.slice(page * pageSize, page * pageSize + pageSize);
return {
items,
scheduled: items,
unscheduled: [] as WorkOrderTableRow[],
totalCount: rows.length,
returnedCount: items.length,
page,
pageSize,
};
}
function lastSearchParams(): WorkOrderBoardSearchQueryParams {
const calls = searchBoard.mock.calls;
return calls[calls.length - 1]?.[0] as WorkOrderBoardSearchQueryParams;
}
function UnscheduledServerOwnedHarness() {
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
return (
<div>
<div data-testid="harness-state">
{`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount}`}
</div>
<button
type="button"
onClick={() => {
filters.setPageSize(PAGE_SIZE);
filters.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true });
}}
>
Apply unscheduled
</button>
<WoTable
tableData={tableData}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday={filters.weekMonday}
onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={() => undefined}
onOpenMedia={() => undefined}
onPatch={() => undefined}
/>
</div>
);
}
describe("unscheduled advanced search is server-owned (SH-338)", () => {
beforeEach(() => {
searchBoard.mockReset();
getBoard.mockReset();
getBoard.mockResolvedValue(emptyBoardResult());
});
it("sends unscheduledOnly=true with no date window and pages server rows/totals", async () => {
// Server owns totals: page 0 returns 3 rows while totalCount spans 2 pages.
const pageZero = [
makeRow({ id: "wo-0", woNumber: "WO-0", scheduledOn: "" }),
// Server owns row filtering: a dated row on the page must still render.
makeRow({ id: "wo-1", woNumber: "WO-1", scheduledOn: "2026-08-17" }),
makeRow({ id: "wo-2", woNumber: "WO-2", scheduledOn: "" }),
];
const pageOne = [
makeRow({ id: "wo-25", woNumber: "WO-25", scheduledOn: "" }),
makeRow({ id: "wo-26", woNumber: "WO-26", scheduledOn: "" }),
];
const serverTotal = PAGE_SIZE + 2;
searchBoard.mockImplementation(async (params: WorkOrderBoardSearchQueryParams) => ({
items: (params.page ?? 0) === 0 ? pageZero : pageOne,
scheduled: (params.page ?? 0) === 0 ? pageZero : pageOne,
unscheduled: [] as WorkOrderTableRow[],
totalCount: serverTotal,
returnedCount: ((params.page ?? 0) === 0 ? pageZero : pageOne).length,
page: params.page ?? 0,
pageSize: params.pageSize ?? PAGE_SIZE,
}));
renderWithProviders(<UnscheduledServerOwnedHarness />, { withAuth: false });
fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" }));
await waitFor(() => {
expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=3");
});
const params = lastSearchParams();
expect(params.unscheduledOnly).toBe(true);
expect(params.datePreset).toBeUndefined();
expect(params.dateFrom).toBeUndefined();
expect(params.dateTo).toBeUndefined();
expect(params.page).toBe(0);
expect(params.pageSize).toBe(PAGE_SIZE);
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.getByText("WO-1")).toBeInTheDocument();
expect(screen.getByText("WO-2")).toBeInTheDocument();
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument();
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
await waitFor(() => {
expect(screen.getByText("WO-25")).toBeInTheDocument();
expect(screen.getByText("WO-26")).toBeInTheDocument();
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
});
expect(lastSearchParams().page).toBe(1);
expect(screen.getByTestId("harness-state")).toHaveTextContent("rows=2");
expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument();
}, 15000);
it("shows the adv empty state when the server returns zero unscheduled results", async () => {
searchBoard.mockResolvedValue(pagedResult([], 0, PAGE_SIZE));
renderWithProviders(<UnscheduledServerOwnedHarness />, { withAuth: false });
fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" }));
await waitFor(() => {
expect(screen.getByText("No work orders match these filters")).toBeInTheDocument();
});
expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=0 total=0");
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument();
}, 15000);
});

View file

@ -22,4 +22,24 @@ describe("WorkOrdersAdvancedFiltersBanner count semantics", () => {
expect(screen.getByText("150 work orders")).toBeInTheDocument();
});
it("shows Date / Unscheduled and the filtered list count when the toggle is on", () => {
render(
<WorkOrdersAdvancedFiltersBanner
applied={{
...EMPTY_ADVANCED_FILTERS,
unscheduledOnly: true,
}}
totalCount={3}
sites={[]}
users={[]}
onClearChip={() => undefined}
onClearAll={() => undefined}
/>,
);
expect(screen.getByText("Date")).toBeInTheDocument();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("3 work orders")).toBeInTheDocument();
});
});

View file

@ -343,6 +343,53 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
});
it("applies Unscheduled from Date range and sends unscheduledOnly without a faked window", () => {
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: /Date range/i }));
const custom = within(sheet).getByRole("button", { name: /^Custom$/i });
const unscheduled = within(sheet).getByRole("button", { name: /^Unscheduled$/i });
expect(custom.compareDocumentPosition(unscheduled) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unscheduled).toHaveAttribute("aria-pressed", "false");
fireEvent.click(unscheduled);
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument();
expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument();
fireEvent.click(custom);
expect(within(sheet).getByLabelText(/^From$/i)).toBeInTheDocument();
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.unscheduledOnly).toBe(true);
expect(applied.dateRange).toBe("custom");
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.unscheduledOnly).toBe(true);
expect(params.datePreset).toBeUndefined();
expect(params.dateFrom).toBeUndefined();
expect(params.dateTo).toBeUndefined();
});
it("places Aveta Compliance immediately above Uplift", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",

165
src/test/data/areas.test.ts Normal file
View file

@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
AREA_ZONE_STATE_CODES,
AREA_ZONES,
STATE_AREA_ENTRIES,
US_STATE_CODES,
expandAreasToStateCodes,
formatStateOptionLabel,
getAreaForState,
normalizeStateCode,
} from "@/data/areas";
const EAST = [
"CT",
"DE",
"GA",
"MA",
"MD",
"ME",
"NC",
"NH",
"NJ",
"NY",
"PA",
"RI",
"SC",
"VA",
"VT",
"WV",
];
const CENTRAL = [
"AL",
"AR",
"FL",
"IA",
"IL",
"IN",
"KS",
"KY",
"LA",
"MI",
"MN",
"MO",
"MS",
"ND",
"NE",
"OH",
"OK",
"SD",
"TN",
"WI",
];
const WEST = ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"];
describe("state area catalog", () => {
it("covers exactly the 50 states with unique codes and names", () => {
expect(STATE_AREA_ENTRIES).toHaveLength(50);
expect(US_STATE_CODES).toHaveLength(50);
expect(new Set(US_STATE_CODES).size).toBe(50);
expect(new Set(STATE_AREA_ENTRIES.map((entry) => entry.name)).size).toBe(50);
});
it("excludes DC and territories", () => {
expect(US_STATE_CODES).not.toContain("DC");
expect(US_STATE_CODES).not.toContain("PR");
expect(US_STATE_CODES).not.toContain("GU");
expect(US_STATE_CODES).not.toContain("VI");
expect(US_STATE_CODES).not.toContain("AS");
expect(US_STATE_CODES).not.toContain("MP");
});
it("maps every state to exactly one confirmed zone with counts", () => {
expect([...AREA_ZONE_STATE_CODES.East]).toEqual(EAST);
expect([...AREA_ZONE_STATE_CODES.Central]).toEqual(CENTRAL);
expect([...AREA_ZONE_STATE_CODES.West]).toEqual(WEST);
expect([...AREA_ZONE_STATE_CODES.California]).toEqual(["CA"]);
expect(EAST).toHaveLength(16);
expect(CENTRAL).toHaveLength(20);
expect(WEST).toHaveLength(13);
for (const entry of STATE_AREA_ENTRIES) {
expect(AREA_ZONES).toContain(entry.zone);
expect(entry.code).toMatch(/^[A-Z]{2}$/);
expect(entry.name.length).toBeGreaterThan(0);
}
});
it("keeps the canonical zone order", () => {
expect([...AREA_ZONES]).toEqual(["East", "Central", "West", "California"]);
expect(US_STATE_CODES[0]).toBe("CT");
expect(US_STATE_CODES[49]).toBe("CA");
});
});
describe("formatStateOptionLabel", () => {
it("renders Name (CODE)", () => {
expect(formatStateOptionLabel("TX")).toBe("Texas (TX)");
expect(formatStateOptionLabel("CA")).toBe("California (CA)");
});
});
describe("normalizeStateCode", () => {
it("accepts canonical codes in any case", () => {
expect(normalizeStateCode("TX")).toBe("TX");
expect(normalizeStateCode("tx")).toBe("TX");
expect(normalizeStateCode("Mo")).toBe("MO");
});
it("normalizes legacy full-name and lowercase values", () => {
expect(normalizeStateCode("Texas")).toBe("TX");
expect(normalizeStateCode("texas")).toBe("TX");
expect(normalizeStateCode(" Illinois ")).toBe("IL");
expect(normalizeStateCode("NORTH CAROLINA")).toBe("NC");
expect(normalizeStateCode("California")).toBe("CA");
});
it("returns null for empty, unknown, and non-state values", () => {
expect(normalizeStateCode("")).toBeNull();
expect(normalizeStateCode(" ")).toBeNull();
expect(normalizeStateCode(null)).toBeNull();
expect(normalizeStateCode(undefined)).toBeNull();
expect(normalizeStateCode("ZZ")).toBeNull();
expect(normalizeStateCode("DC")).toBeNull();
expect(normalizeStateCode("Puerto Rico")).toBeNull();
});
});
describe("getAreaForState", () => {
it("derives the confirmed zone per state", () => {
expect(getAreaForState("CT")).toBe("East");
expect(getAreaForState("IL")).toBe("Central");
expect(getAreaForState("TX")).toBe("West");
expect(getAreaForState("CA")).toBe("California");
});
it("derives the zone from legacy full-name values", () => {
expect(getAreaForState("illinois")).toBe("Central");
expect(getAreaForState("Texas")).toBe("West");
});
it("returns null for unknown states", () => {
expect(getAreaForState("XX")).toBeNull();
expect(getAreaForState("")).toBeNull();
expect(getAreaForState(null)).toBeNull();
});
});
describe("expandAreasToStateCodes", () => {
it("returns an empty list for an empty selection", () => {
expect(expandAreasToStateCodes([])).toEqual([]);
});
it("expands a single zone in canonical code order", () => {
expect(expandAreasToStateCodes(["East"])).toEqual(EAST);
expect(expandAreasToStateCodes(["California"])).toEqual(["CA"]);
expect(expandAreasToStateCodes(["West"])).toEqual(WEST);
});
it("expands multiple zones in canonical zone order and dedupes", () => {
expect(expandAreasToStateCodes(["West", "California"])).toEqual([...WEST, "CA"]);
expect(expandAreasToStateCodes(["California", "West", "West"])).toEqual([...WEST, "CA"]);
expect(expandAreasToStateCodes([...AREA_ZONES])).toEqual(US_STATE_CODES);
});
});

View file

@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: vi.fn(),
apiPut: vi.fn(),
apiDelete: vi.fn(),
}));
import { expandAreasToStateCodes } from "@/data/areas";
import { locationsApi } from "@/domain/locations/api/locations-api";
describe("locationsApi.getList states query param", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue([]);
});
it("joins state codes into a comma-separated param", async () => {
await locationsApi.getList({ states: ["TX", "MO"] });
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, {
searchParams: { search: "", page: 1, pageSize: 12, states: "TX,MO" },
});
});
it("omits the states param when no states are provided", async () => {
await locationsApi.getList({});
const [, config] = apiGet.mock.calls[0];
expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 });
expect("states" in config.searchParams).toBe(false);
});
it("omits the states param when the states list is empty", async () => {
await locationsApi.getList({ states: [] });
const [, config] = apiGet.mock.calls[0];
expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 });
expect("states" in config.searchParams).toBe(false);
});
it("preserves the canonical area expansion order in the joined value", async () => {
await locationsApi.getList({ states: expandAreasToStateCodes(["West", "California"]) });
const [, config] = apiGet.mock.calls[0];
expect(config.searchParams.states).toBe("AK,AZ,CO,HI,ID,MT,NM,NV,OR,TX,UT,WA,WY,CA");
});
it("keeps existing search and pagination params", async () => {
await locationsApi.getList({ search: "dallas", page: 2, pageSize: 24, states: ["TX"] });
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, {
searchParams: { search: "dallas", page: 2, pageSize: 24, states: "TX" },
});
});
});

View file

@ -363,6 +363,42 @@ describe("workOrdersApi.searchBoard", () => {
expect(result.items[0]?.pm).toBe("PM Filter");
expect(result.items[0]?.scheduledOn).toBe("2026-07-15");
});
it("serializes unscheduledOnly=true with no date window on the exact wire shape (SH-338)", async () => {
await workOrdersApi.searchBoard({
page: 0,
pageSize: 50,
unscheduledOnly: true,
statuses: [3],
sortBy: "scheduledDate",
sortDir: "asc",
});
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(String(searchParams)).toBe(
"page=0&pageSize=50&unscheduledOnly=true&statuses=3&sortBy=scheduledDate&sortDir=asc",
);
expect(searchParams.get("unscheduledOnly")).toBe("true");
expect(searchParams.has("datePreset")).toBe(false);
expect(searchParams.has("dateFrom")).toBe(false);
expect(searchParams.has("dateTo")).toBe(false);
});
it("keeps the legacy wire shape (datePreset, no unscheduledOnly) when the toggle is off", async () => {
await workOrdersApi.searchBoard({
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
page: 0,
pageSize: 50,
});
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(searchParams.has("unscheduledOnly")).toBe(false);
expect(searchParams.get("datePreset")).toBe("Custom");
expect(searchParams.get("dateFrom")).toBe("2000-01-01");
expect(searchParams.get("dateTo")).toBe("2099-12-31");
});
});
describe("workOrdersApi.findByWoNumber", () => {

View file

@ -39,6 +39,24 @@ describe("countAdvancedFilters", () => {
};
expect(countAdvancedFilters(filters)).toBe(3);
});
it("counts Unscheduled +1 with the this-week default still in place", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
unscheduledOnly: true,
};
expect(filters.dateRange).toBe("this-week");
expect(countAdvancedFilters(filters)).toBe(1);
});
it("counts Unscheduled independently of a non-default date range", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
dateRange: "last-week" as const,
unscheduledOnly: true,
};
expect(countAdvancedFilters(filters)).toBe(2);
});
});
describe("buildAdvancedFilterChips", () => {
@ -66,6 +84,27 @@ describe("buildAdvancedFilterChips", () => {
});
});
it("builds a Date / Unscheduled chip whose clear only turns the toggle off", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
dateRange: "last-week" as const,
unscheduledOnly: true,
};
const chips = buildAdvancedFilterChips(filters);
const unscheduled = chips.find((chip) => chip.key === "unscheduled");
expect(unscheduled).toMatchObject({
key: "unscheduled",
group: "Date",
value: "Unscheduled",
});
expect(chips.some((chip) => chip.key === "date" && chip.value === "Last week")).toBe(true);
const cleared = unscheduled?.clear(filters);
expect(cleared?.unscheduledOnly).toBe(false);
expect(cleared?.dateRange).toBe("last-week");
});
it("creates a separate unassigned chip for dispatchers", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
@ -101,6 +140,15 @@ describe("clearAdvancedFilterGroup", () => {
expect(next).toBeNull();
});
it("clears only Unscheduled and returns null when it was the last chip", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
unscheduledOnly: true,
};
const next = clearAdvancedFilterGroup(filters, "unscheduled");
expect(next).toBeNull();
});
it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),

View file

@ -7,6 +7,7 @@ import {
WORK_ORDER_TYPE_API,
} from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { queryKeys } from "@/infra/query-key/query-key";
import {
BOARD_SEARCH_MAX_PAGE_SIZE,
buildBoardSearchParamsRecord,
@ -88,6 +89,7 @@ describe("toBoardSearchQueryParams", () => {
expect(params.datePreset).toBe("Custom");
expect(params.dateFrom).toBe("2000-01-01");
expect(params.dateTo).toBe("2099-12-31");
expect(params.unscheduledOnly).toBeUndefined();
});
it("forwards chrome pageSize 50 instead of the search max", () => {
@ -133,6 +135,59 @@ describe("toBoardSearchQueryParams", () => {
expect(thisWeek.dateTo).toBe("2099-12-31");
});
it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {
const withDefaultRange = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true },
});
expect(withDefaultRange.unscheduledOnly).toBe(true);
expect(withDefaultRange.datePreset).toBeUndefined();
expect(withDefaultRange.dateFrom).toBeUndefined();
expect(withDefaultRange.dateTo).toBeUndefined();
const overridingLastWeek = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
dateRange: "last-week",
unscheduledOnly: true,
},
});
expect(overridingLastWeek.unscheduledOnly).toBe(true);
expect(overridingLastWeek.datePreset).toBeUndefined();
expect(overridingLastWeek.dateFrom).toBeUndefined();
expect(overridingLastWeek.dateTo).toBeUndefined();
const record = buildBoardSearchParamsRecord(overridingLastWeek);
expect(record.unscheduledOnly).toBe(true);
expect(record).not.toHaveProperty("datePreset");
expect(record).not.toHaveProperty("dateFrom");
expect(record).not.toHaveProperty("dateTo");
});
it("keeps applying every other facet alongside unscheduledOnly", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
unscheduledOnly: true,
statuses: new Set(["Scheduled"]),
severities: new Set([4]),
pmTypes: new Set(["HVAC"]),
},
});
expect(params.unscheduledOnly).toBe(true);
expect(params.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
expect(params.severities).toEqual([4]);
expect(params.pmTypes).toEqual(["HVAC"]);
});
it("omits unscheduledOnly and keeps the wide Custom default when the toggle is off", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-week" },
});
expect(params.unscheduledOnly).toBeUndefined();
expect(params.datePreset).toBe("LastWeek");
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
});
it("maps Overdue-only advanced filter to overdue=true without types", () => {
const params = toBoardSearchQueryParams({
filters: {
@ -188,6 +243,7 @@ describe("toBoardSearchQueryParams", () => {
expect(params.internalOnly).toBeUndefined();
expect(params.hasUplift).toBeUndefined();
expect(params.upliftStatuses).toBeUndefined();
expect(params.unscheduledOnly).toBeUndefined();
});
it("maps service types and completion docs onto board search params", () => {
@ -350,3 +406,30 @@ describe("toUnassignedQueueQueryParams", () => {
expect(record).not.toHaveProperty("weekEnd");
});
});
describe("boardSearch query-key isolation", () => {
it("separates unscheduledOnly=true from the same facets without it", () => {
const without = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true },
});
const withUnscheduled = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true, unscheduledOnly: true },
});
expect(queryKeys.workOrders.boardSearch(without)).not.toEqual(
queryKeys.workOrders.boardSearch(withUnscheduled),
);
});
it("separates pages under the same unscheduledOnly filter", () => {
const base = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true },
page: 0,
});
const pageOne = { ...base, page: 1 };
expect(queryKeys.workOrders.boardSearch(base)).not.toEqual(
queryKeys.workOrders.boardSearch(pageOne),
);
});
});

View file

@ -431,6 +431,29 @@ describe("matchesAdvancedFilters", () => {
matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: false }), filters),
).toBe(false);
});
it("keeps only undated rows when Unscheduled overrides any date range", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "last-week" as const,
unscheduledOnly: true,
};
const dated = makeRow({ scheduledOn: "2026-06-23" });
const weekOnly = makeRow({
scheduledOn: "",
targetWeek: "2026-07-06",
scheduleWeekOnly: true,
});
const undatedAssigned = makeRow({
scheduledOn: "",
dispatcherId: "u1",
dispatcherName: "Alice",
});
expect(matchesAdvancedFilters(dated, filters)).toBe(false);
expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true);
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
});
});
describe("computeIsPastDue", () => {