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>
This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:38:18 -03:00 • committed by GitHub
parent 861f820715
commit ebc401dcb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1242 additions and 279 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>
);
}

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

@ -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();
});
});

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" },
});
});
});