mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
SH-273: derive Site Area from State (#190)
Some checks are pending
Some checks are pending
* 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:
parent
861f820715
commit
ebc401dcb7
17 changed files with 1242 additions and 279 deletions
22
src/app/(protected)/locations/_components/area-facet.tsx
Normal file
22
src/app/(protected)/locations/_components/area-facet.tsx
Normal 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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{target?.name}"? 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
102
src/app/(protected)/locations/_components/locations-table.tsx
Normal file
102
src/app/(protected)/locations/_components/locations-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
49
src/app/(protected)/locations/_components/state-select.tsx
Normal file
49
src/app/(protected)/locations/_components/state-select.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{deleteTarget?.name}"? 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
170
src/data/areas.ts
Normal 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]));
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
103
src/test/app/(protected)/locations/location-form-state.test.tsx
Normal file
103
src/test/app/(protected)/locations/location-form-state.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
139
src/test/app/(protected)/locations/locations-list-area.test.tsx
Normal file
139
src/test/app/(protected)/locations/locations-list-area.test.tsx
Normal 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"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/test/app/(protected)/locations/state-select.test.tsx
Normal file
137
src/test/app/(protected)/locations/state-select.test.tsx
Normal 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
165
src/test/data/areas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
src/test/domain/locations/api/locations-list-api.test.ts
Normal file
60
src/test/domain/locations/api/locations-list-api.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue