From a75b9c8be625bfb6418799f68224ae05db1d9b10 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:43:30 -0300 Subject: [PATCH] feat(sites): Add Site modal and site detail panel with in-place edit - Sites page: Add Site modal with Site Code, Client, address, State/Area, optional Site Phone and Email, points of contact - Duplicate Site Code shows inline under the field and keeps the modal open - Row opens a non-modal detail panel; Street Address links to Google Maps; key-less map preview - Edit switches the panel in place with Site Code read-only; Save needs a valid change, Cancel makes no call - Site Phone is sent independently of the contacts; zip and title are carried through edits - Retire the separate new/edit location pages --- docs/FEATURE_PRIORITIZATION.md | 2 +- .../locations/_components/add-site-dialog.tsx | 94 ++++++++ .../locations/_components/client-select.tsx | 64 ++++++ .../location-contacts-field-array.tsx | 4 +- .../_components/location-form-fields.tsx | 143 ------------ .../locations/_components/location-form.tsx | 141 ------------ .../locations/_components/locations-table.tsx | 72 +++--- .../_components/site-detail-edit.tsx | 68 ++++++ .../_components/site-detail-panel.tsx | 115 ++++++++++ .../_components/site-detail-view.tsx | 76 +++++++ .../_components/site-form-fields.tsx | 97 +++++++++ .../locations/_components/site-form-values.ts | 24 ++ .../_components/site-map-preview.tsx | 39 ++++ src/app/(protected)/locations/edit/[id].tsx | 1 - src/app/(protected)/locations/index.tsx | 58 +++-- src/app/(protected)/locations/new.tsx | 1 - .../list/table/cells/site-dialog-helpers.ts | 5 +- .../wizard-step-location-site-details.tsx | 8 +- .../locations/mappers/location-mapper.ts | 26 ++- .../locations/schemas/location-schema.ts | 43 ++-- src/domain/locations/types/location.ts | 10 +- .../use-cases/use-create-location.ts | 19 +- .../use-cases/use-update-location.ts | 25 ++- .../locations/utils/site-code-conflict.ts | 14 ++ src/domain/locations/utils/site-maps.ts | 23 ++ .../locations/add-site-dialog.test.tsx | 164 ++++++++++++++ .../location-contacts-field-array.test.tsx | 3 +- .../locations/location-form-state.test.tsx | 103 --------- .../locations-contacts-cell.test.tsx | 2 + .../locations/locations-list-area.test.tsx | 2 +- .../locations/site-detail-panel.test.tsx | 205 ++++++++++++++++++ .../locations/state-select.test.tsx | 2 +- .../locations/mappers/location-mapper.test.ts | 64 +++++- .../locations/schemas/location-schema.test.ts | 45 +++- 34 files changed, 1244 insertions(+), 518 deletions(-) create mode 100644 src/app/(protected)/locations/_components/add-site-dialog.tsx create mode 100644 src/app/(protected)/locations/_components/client-select.tsx delete mode 100644 src/app/(protected)/locations/_components/location-form-fields.tsx delete mode 100644 src/app/(protected)/locations/_components/location-form.tsx create mode 100644 src/app/(protected)/locations/_components/site-detail-edit.tsx create mode 100644 src/app/(protected)/locations/_components/site-detail-panel.tsx create mode 100644 src/app/(protected)/locations/_components/site-detail-view.tsx create mode 100644 src/app/(protected)/locations/_components/site-form-fields.tsx create mode 100644 src/app/(protected)/locations/_components/site-form-values.ts create mode 100644 src/app/(protected)/locations/_components/site-map-preview.tsx delete mode 100644 src/app/(protected)/locations/edit/[id].tsx delete mode 100644 src/app/(protected)/locations/new.tsx create mode 100644 src/domain/locations/utils/site-code-conflict.ts create mode 100644 src/domain/locations/utils/site-maps.ts create mode 100644 src/test/app/(protected)/locations/add-site-dialog.test.tsx delete mode 100644 src/test/app/(protected)/locations/location-form-state.test.tsx create mode 100644 src/test/app/(protected)/locations/site-detail-panel.test.tsx diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md index aa52e770..c9bd0214 100644 --- a/docs/FEATURE_PRIORITIZATION.md +++ b/docs/FEATURE_PRIORITIZATION.md @@ -26,7 +26,7 @@ | 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth | | 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth | | 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth | -| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts | +| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts | | 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth | | 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) | | 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) | diff --git a/src/app/(protected)/locations/_components/add-site-dialog.tsx b/src/app/(protected)/locations/_components/add-site-dialog.tsx new file mode 100644 index 00000000..6c73ab0e --- /dev/null +++ b/src/app/(protected)/locations/_components/add-site-dialog.tsx @@ -0,0 +1,94 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; +import { + DUPLICATE_SITE_CODE_MESSAGE, + isDuplicateSiteCodeError, +} from "@/domain/locations/utils/site-code-conflict"; +import { SiteFormFields } from "./site-form-fields"; + +interface AddSiteDialogProps { + open: boolean; + onClose: () => void; +} + +/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */ +export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) { + const createLocation = useCreateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: locationDefaultValues, + }); + + const close = () => { + form.reset(locationDefaultValues); + createLocation.reset(); + onClose(); + }; + + const onSubmit = (values: LocationFormValues) => { + createLocation.mutate(values, { + onSuccess: close, + onError: (error) => { + if (isDuplicateSiteCodeError(error)) { + form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE }); + } + }, + }); + }; + + return ( + { + if (!createLocation.isPending) { + close(); + } + }} + fullWidth + maxWidth="sm" + aria-labelledby="add-site-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + + + Add Site + + Create a new client site record. + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/client-select.tsx b/src/app/(protected)/locations/_components/client-select.tsx new file mode 100644 index 00000000..514811ba --- /dev/null +++ b/src/app/(protected)/locations/_components/client-select.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { useDebounce } from "@/hooks/use-debounce"; + +type ClientOption = Pick; + +interface ClientSelectProps { + control: Control; + error?: FieldError; + /** Stored client, shown even when it is not in the first page of search results. */ + initialClient?: ClientOption; +} + +/** Searchable client (account) picker bound to `accountId`. */ +export function ClientSelect({ control, error, initialClient }: ClientSelectProps) { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 }); + + const options = useMemo(() => { + const items: ClientOption[] = data?.items ?? []; + const hasInitial = + initialClient !== undefined && + items.some((item) => String(item.id) === String(initialClient.id)); + return initialClient === undefined || hasInitial ? items : [initialClient, ...items]; + }, [data?.items, initialClient]); + + return ( + ( + + options={options} + loading={isLoading} + getOptionLabel={(option) => option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={options.find((option) => String(option.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onBlur={field.onBlur} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + setSearch(value); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx index 9053cbe4..ed3122f0 100644 --- a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -202,7 +202,7 @@ export function LocationContactsFieldArray({ - Contacts + Points of contact diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx deleted file mode 100644 index 39f22296..00000000 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 { LocationContactsFieldArray } from "./location-contacts-field-array"; -import { StateSelect } from "./state-select"; - -interface SharedFieldsProps { - control: Control; - errors: FieldErrors; - register: UseFormRegister; -} - -interface LocationDetailsFieldsProps extends SharedFieldsProps { - accountOptions: AccountListItem[]; - isLoadingAccounts: boolean; - onAccountSearchChange: (value: string) => void; -} - -export function LocationDetailsFields({ - accountOptions, - control, - errors, - isLoadingAccounts, - onAccountSearchChange, - register, -}: LocationDetailsFieldsProps) { - return ( - - - Location Information - - - - ( - 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) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - - ); -} - -export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { - return ( - - - Contact Information - - - - - ( - - Status - - - )} - /> - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx deleted file mode 100644 index b410d43d..00000000 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router"; -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 { 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(); - const navigate = useNavigate(); - const isEdit = Boolean(id); - - const [accountSearch, setAccountSearch] = useState(""); - const debouncedAccountSearch = useDebounce(accountSearch, 300); - - const { data: location, isLoading, error } = useLocationDetail(id); - const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({ - search: debouncedAccountSearch, - page: 1, - pageSize: 50, - }); - const createLocation = useCreateLocation(); - const updateLocation = useUpdateLocation(); - - const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]); - - const { - register, - handleSubmit, - reset, - control, - formState: { errors }, - } = useForm({ - resolver: zodResolver(locationSchema), - defaultValues: locationDefaultValues, - }); - - useEffect(() => { - if (location && isEdit) { - reset({ - name: location.name ?? "", - accountId: location.accountId != null ? String(location.accountId) : "", - address: location.address ?? "", - city: location.city ?? "", - state: normalizeStateCode(location.state) ?? "", - zipCode: location.zipCode ?? "", - contacts: - location.contacts && location.contacts.length > 0 - ? location.contacts - : [{ name: location.contact ?? "", phone: location.phone ?? "" }], - contactEmail: location.contactEmail ?? "", - status: (location.status as LocationFormValues["status"]) || "Active", - }); - } - }, [location, isEdit, reset]); - - const isSaving = createLocation.isPending || updateLocation.isPending; - - const onSubmit = (values: LocationFormValues) => { - if (isEdit && id) { - updateLocation.mutate({ id, values }); - return; - } - createLocation.mutate(values); - }; - - if (isEdit && isLoading) { - return ( - - - - ); - } - - return ( - - - - - - {isEdit ? "Edit Location" : "Create a Location"} - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load location"} - - )} - - - - - - - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index bfd36118..840a06c0 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -1,5 +1,4 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Button, CircularProgress, @@ -21,83 +20,100 @@ import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { isLoading: boolean; onDelete: (row: LocationListItem) => void; - onEdit: (row: LocationListItem) => void; + onOpen: (row: LocationListItem) => void; rows: LocationListItem[]; + selectedId?: string | null; } -export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { +const COLUMN_COUNT = 8; + +export function LocationsTable({ + isLoading, + onDelete, + onOpen, + rows, + selectedId = null, +}: LocationsTableProps) { return ( - Location Name - Address + Site Code + Client + Street Address City State - Phone - Contact - Status + POC Name + POC Phone - Actions + Actions - {isLoading ? ( + {isLoading && ( - + - ) : rows.length === 0 ? ( + )} + {!isLoading && rows.length === 0 && ( - - No locations found + + No sites found - ) : ( + )} + {!isLoading && rows.map((row) => ( - + onOpen(row)} + className="cursor-pointer" + > + {row.clientName || "—"} {row.address || "—"} {row.city || "—"} - {row.phone || "—"} - {row.status || "—"} + {row.phone || "—"} - - onEdit(row)}> - - - onDelete(row)} + aria-label={`Delete site ${row.name}`} + onClick={(event) => { + event.stopPropagation(); + onDelete(row); + }} > - )) - )} + ))}
diff --git a/src/app/(protected)/locations/_components/site-detail-edit.tsx b/src/app/(protected)/locations/_components/site-detail-edit.tsx new file mode 100644 index 00000000..00355440 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-edit.tsx @@ -0,0 +1,68 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box, Button, Stack } from "@mui/material"; +import { + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; +import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; +import { SiteFormFields } from "./site-form-fields"; +import { toSiteFormValues } from "./site-form-values"; + +interface SiteDetailEditProps { + siteId: string; + location: Location; + /** Back to view mode: after Cancel (no API call) or after a successful save. */ + onDone: () => void; +} + +/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */ +export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) { + const updateLocation = useUpdateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: toSiteFormValues(location), + mode: "onChange", + }); + const { isDirty, isValid } = form.formState; + const initialClient = + location.accountId != null && location.clientName + ? { id: location.accountId, name: location.clientName } + : undefined; + + const onSubmit = (values: LocationFormValues) => { + updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone }); + }; + + return ( + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx new file mode 100644 index 00000000..3111bfa5 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import type { Location } from "@/domain/locations/types/location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { SiteDetailEdit } from "./site-detail-edit"; +import { SiteDetailView } from "./site-detail-view"; + +export const SITE_PANEL_WIDTH = 420; + +interface SiteDetailPanelProps { + /** Selected site id; `null` closes the panel. */ + siteId: string | null; + onClose: () => void; +} + +function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) { + return ( + + + + {location?.name || "Site"} + + + {location?.clientName || " "} + + + + + ); +} + +function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) { + const [mode, setMode] = useState<"view" | "edit">("view"); + const { data: location, isLoading, error } = useLocationDetail(siteId); + + return ( + <> + + {isLoading && ( + + + + )} + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load site"} + + )} + {location !== undefined && mode === "edit" && ( + setMode("view")} /> + )} + {location !== undefined && mode === "view" && ( + <> + + + + + + + + )} + + ); +} + +/** + * Non-modal site panel: the table stays interactive, so clicking another row swaps the + * content in place. Escape closes it unless a popup inside already handled the key. + */ +export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) { + const open = siteId !== null; + + useEffect(() => { + if (!open) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !event.defaultPrevented) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + + {siteId !== null && } + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-view.tsx b/src/app/(protected)/locations/_components/site-detail-view.tsx new file mode 100644 index 00000000..9ba956f6 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-view.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Link, Stack, Typography } from "@mui/material"; +import { getAreaForState } from "@/data/areas"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; +import { SiteMapPreview } from "./site-map-preview"; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +function formatState(state?: string): string { + const area = getAreaForState(state); + const code = state?.trim() ?? ""; + if (!code) { + return "—"; + } + return area === null ? code : `${code} · Area: ${area}`; +} + +/** Read-only site details, in the prototype order. */ +export function SiteDetailView({ location }: { location: Location }) { + const contacts = location.contacts ?? []; + + return ( + + {location.name || "—"} + {location.clientName || "—"} + + {Boolean(location.address) && ( + + {location.address} + + )} + {!location.address && "—"} + + + {location.city || "—"} + {formatState(location.state)} + + + {location.sitePhone || "—"} + {location.contactEmail || "—"} + + {contacts.length === 0 && "—"} + + {contacts.map((contact, index) => ( +
  • + {index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name} + {Boolean(contact.phone) && ` · ${contact.phone}`} +
  • + ))} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx new file mode 100644 index 00000000..cbff09cf --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -0,0 +1,97 @@ +import { Controller, useWatch, type UseFormReturn } from "react-hook-form"; +import { Stack, TextField } from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { ClientSelect } from "./client-select"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; +import { SiteMapPreview } from "./site-map-preview"; +import { StateSelect } from "./state-select"; + +interface SiteFormFieldsProps { + form: UseFormReturn; + /** Edit mode: Site Code is immutable after creation. */ + siteCodeReadOnly?: boolean; + initialClient?: Pick; +} + +/** Shared Add Site / edit-mode fields, in the prototype order. */ +export function SiteFormFields({ + form, + siteCodeReadOnly = false, + initialClient, +}: SiteFormFieldsProps) { + const { + control, + register, + formState: { errors }, + } = form; + const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] }); + + return ( + + ( + field.onChange(event.target.value.toUpperCase())} + label="Site Code" + placeholder="e.g. AB12" + required={!siteCodeReadOnly} + disabled={siteCodeReadOnly} + error={Boolean(errors.name)} + helperText={ + errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined) + } + slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }} + fullWidth + /> + )} + /> + + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-values.ts b/src/app/(protected)/locations/_components/site-form-values.ts new file mode 100644 index 00000000..94cb859e --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-values.ts @@ -0,0 +1,24 @@ +import { normalizeStateCode } from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; + +/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */ +export function toSiteFormValues(location: Location): LocationFormValues { + const contacts = + location.contacts && location.contacts.length > 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }]; + + return { + name: location.name ?? "", + accountId: location.accountId != null ? String(location.accountId) : "", + address: location.address ?? "", + city: location.city ?? "", + state: normalizeStateCode(location.state) ?? "", + sitePhone: location.sitePhone ?? "", + contactEmail: location.contactEmail ?? "", + contacts, + zipCode: location.zipCode ?? "", + title: location.title ?? "", + }; +} diff --git a/src/app/(protected)/locations/_components/site-map-preview.tsx b/src/app/(protected)/locations/_components/site-map-preview.tsx new file mode 100644 index 00000000..4ff33a9d --- /dev/null +++ b/src/app/(protected)/locations/_components/site-map-preview.tsx @@ -0,0 +1,39 @@ +import { Box, Typography } from "@mui/material"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps"; + +type SiteMapPreviewProps = Pick; + +/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ +export function SiteMapPreview(props: SiteMapPreviewProps) { + const complete = hasCompleteSiteAddress(props); + + return ( + + + Location preview + + {complete && ( + + )} + {!complete && ( + + + Enter an address to preview the location + + + )} + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx deleted file mode 100644 index f25ea225..00000000 --- a/src/app/(protected)/locations/edit/[id].tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 79674137..52308aad 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useCallback, useMemo, useState } from "react"; import { Alert, Box, @@ -15,20 +14,24 @@ import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-locat import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; +import { AddSiteDialog } from "./_components/add-site-dialog"; import { AreaFacet } from "./_components/area-facet"; import { LocationDeleteDialog } from "./_components/location-delete-dialog"; import { LocationsTable } from "./_components/locations-table"; +import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel"; 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([]); const [deleteTarget, setDeleteTarget] = useState(null); + const [isAddOpen, setIsAddOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); const debouncedSearch = useDebounce(search, 300); const deleteLocation = useDeleteLocation(); + const closePanel = useCallback(() => setSelectedId(null), []); const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); @@ -52,13 +55,20 @@ export default function LocationsListPage() { return; } + const deletedId = String(deleteTarget.id); deleteLocation.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), + onSuccess: () => { + setDeleteTarget(null); + setSelectedId((current) => (current === deletedId ? null : current)); + }, }); }; return ( - + - + - Locations + Sites - + {totalCount} total {isFetching && !isLoading && } - + { @@ -109,23 +102,24 @@ export default function LocationsListPage() { setSearch(event.target.value); setPage(0); }} - placeholder="Search locations..." + placeholder="Search sites..." className="min-w-[220px]" /> - {Boolean(error) && ( - {error instanceof Error ? error.message : "Failed to load locations"} + {error instanceof Error ? error.message : "Failed to load sites"} )} navigate(`/locations/edit/${row.id}`)} + selectedId={selectedId} + onOpen={(row) => setSelectedId(String(row.id))} onDelete={setDeleteTarget} /> + setIsAddOpen(false)} /> + contact.name !== "" || contact.phone !== ""); } +/** + * Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`, + * `title` and `notes` are sent only when present so an update keeps the stored values. + */ export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, @@ -206,19 +217,24 @@ export function mapLocationToBackend(location: unknown): Record const mainContact = contacts[0]; const payload: Record = { - name: item.name ?? "", + name: typeof item.name === "string" ? item.name.trim() : "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: mainContact ? mainContact.phone : (item.phone ?? ""), - contact: mainContact ? mainContact.name : (item.contact ?? ""), - contactEmail: item.contactEmail ?? "", - status: item.status ?? "Active", + phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "", + contact: mainContact ? mainContact.name : "", + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "", accountId: item.accountId ?? "", contacts, }; + for (const key of ["status", "title", "notes"] as const) { + if (typeof item[key] === "string") { + payload[key] = item[key]; + } + } + const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { payload.id = id; diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 7c9e878c..a45b7b90 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,29 +1,43 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationContactSchema = z.object({ id: z.number().int().positive().optional(), name: z .string() .trim() - .min(1, "Contact person is required") - .max(100, "Contact person cannot exceed 100 characters"), - phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), + .min(1, "Contact name is required") + .max(100, "Contact name cannot exceed 100 characters"), + phone: z + .string() + .trim() + .min(1, "Contact phone is required") + .max(20, "Contact phone cannot exceed 20 characters"), }); +/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */ 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"), + name: z + .string() + .trim() + .min(1, "Site Code is required") + .max(50, "Site Code cannot exceed 50 characters"), + accountId: z.string().min(1, "Client is required"), + address: z.string().trim().min(1, "Street Address is required"), + city: z.string().trim().min(1, "City 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"), + sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"), + contactEmail: z + .string() + .trim() + .refine((value) => value === "" || z.email().safeParse(value).success, { + message: "Invalid email address", + }), contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), - contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), - status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), + zipCode: z.string(), + title: z.string(), }); export type LocationFormValues = z.infer; @@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = { address: "", city: "", state: "", - zipCode: "", - contacts: [{ name: "", phone: "" }], + sitePhone: "", contactEmail: "", - status: "Active", + contacts: [{ name: "", phone: "" }], + zipCode: "", + title: "", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 7fa99625..77cadb17 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -10,6 +10,10 @@ export interface LocationContact { export interface LocationListItem { id: string | number; name: string; + /** Client (account) display name. */ + clientName: string; + /** Site Phone, independent of the contacts. */ + sitePhone: string; address: string; city: string; state: string; @@ -26,6 +30,10 @@ export interface LocationListItem { export interface Location { id?: string | number; name?: string; + title?: string; + clientName?: string; + sitePhone?: string; + notes?: string; address?: string; city?: string; state?: string; @@ -61,5 +69,3 @@ export interface LocationsListResult { page: number; pageSize: number; } - -export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; diff --git a/src/domain/locations/use-cases/use-create-location.ts b/src/domain/locations/use-cases/use-create-location.ts index 421de42c..22bbd202 100644 --- a/src/domain/locations/use-cases/use-create-location.ts +++ b/src/domain/locations/use-cases/use-create-location.ts @@ -1,24 +1,27 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; +import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useCreateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */ +export function useCreateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (values: LocationFormValues) => locationsApi.create(values), + mutationFn: async (values: LocationFormValues) => { + await locationsApi.create({ ...values, status: "Active" }); + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location created successfully!"); - navigate("/locations"); + toast.success("Site added"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create location"); + if (isDuplicateSiteCodeError(error)) { + return; + } + toast.error(error.message || "Failed to add site"); }, }); } diff --git a/src/domain/locations/use-cases/use-update-location.ts b/src/domain/locations/use-cases/use-update-location.ts index 02e271c6..05d8b874 100644 --- a/src/domain/locations/use-cases/use-update-location.ts +++ b/src/domain/locations/use-cases/use-update-location.ts @@ -1,9 +1,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; import { queryKeys } from "@/infra/query-key/query-key"; interface UpdateLocationInput { @@ -11,20 +9,25 @@ interface UpdateLocationInput { values: LocationFormValues; } -export function useUpdateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Saves a site. The response carries no site, so the list and detail are refetched. */ +export function useUpdateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values), - onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) }); - toast.success("Location updated successfully!"); - navigate("/locations"); + mutationFn: async ({ id, values }: UpdateLocationInput) => { + await locationsApi.update(id, values); + }, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ + queryKey: queryKeys.locations.detail(String(variables.id)), + }), + ]); + toast.success("Site updated"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update location"); + toast.error(error.message || "Failed to update site"); }, }); } diff --git a/src/domain/locations/utils/site-code-conflict.ts b/src/domain/locations/utils/site-code-conflict.ts new file mode 100644 index 00000000..5c2e8e2a --- /dev/null +++ b/src/domain/locations/utils/site-code-conflict.ts @@ -0,0 +1,14 @@ +import { isHTTPError } from "ky"; + +export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists."; + +/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */ +export function isDuplicateSiteCodeError(error: unknown): boolean { + if (!isHTTPError(error)) { + return false; + } + const data = error.data; + const code = + data && typeof data === "object" ? (data as Record).code : undefined; + return error.response.status === 409 || code === "DuplicateSiteCode"; +} diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts new file mode 100644 index 00000000..28a75792 --- /dev/null +++ b/src/domain/locations/utils/site-maps.ts @@ -0,0 +1,23 @@ +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; + +type SiteAddress = Pick; + +/** Google Maps search link for a site address; opens in a new tab. */ +export function buildSiteMapsUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +/** True once Street, City and State are all filled, so a map preview can render. */ +export function hasCompleteSiteAddress(location: SiteAddress): boolean { + return [location.address, location.city, location.state].every( + (part) => (part?.trim() ?? "") !== "", + ); +} + +/** Key-less Google Maps embed URL for the read-only preview. */ +export function buildSiteMapEmbedUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; +} diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx new file mode 100644 index 00000000..3cef571e --- /dev/null +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -0,0 +1,164 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { HTTPError } from "ky"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { renderWithProviders } from "@/test/test-utils"; + +const create = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { create: (...args: unknown[]) => create(...args) }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [{ id: 7, name: "Acme Logistics" }], totalCount: 1, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +function duplicateCodeError(): HTTPError { + const body = { + status: "Conflict", + message: "This site code already exists.", + code: "DuplicateSiteCode", + }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 409 }), + new Request("https://api.example.test/api/locations"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function renderDialog() { + const onClose = vi.fn(); + renderWithProviders(, { withAuth: false }); + return { onClose, dialog: screen.getByRole("dialog", { name: /add site/i }) }; +} + +async function pickOption(label: string, optionName: string, search: string) { + const input = screen.getByRole("combobox", { name: label }); + input.focus(); + fireEvent.mouseDown(input); + fireEvent.change(input, { target: { value: search } }); + fireEvent.click(await screen.findByRole("option", { name: optionName })); +} + +async function fillRequiredFields(dialog: HTMLElement) { + fireEvent.change(within(dialog).getByRole("textbox", { name: "Site Code" }), { + target: { value: "dal1" }, + }); + await pickOption("Client", "Acme Logistics", "Acme"); + fireEvent.change(within(dialog).getByRole("textbox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + await pickOption("State", "Texas (TX)", "texas"); + fireEvent.change(within(dialog).getByLabelText(/^Contact Name \(Main\)/), { + target: { value: "Jane" }, + }); + fireEvent.change(within(dialog).getByLabelText(/^Phone/), { + target: { value: "(421) 433-0032" }, + }); +} + +describe("AddSiteDialog", () => { + beforeEach(() => { + create.mockReset(); + }); + + it("blocks submission and names every missing required field", async () => { + const { dialog } = renderDialog(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + for (const message of [ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]) { + expect(await within(dialog).findByText(message)).toBeInTheDocument(); + } + expect(create).not.toHaveBeenCalled(); + }); + + it("shows the map placeholder until Street, City and State are filled, then the preview", async () => { + const { dialog } = renderDialog(); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + await fillRequiredFields(dialog); + + const map = within(dialog).getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX&output=embed", + ); + expect(within(dialog).getByText("Area: West")).toBeInTheDocument(); + }); + + it("creates the site with an upper-cased code, optional fields blank, then closes", async () => { + create.mockResolvedValue({}); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "DAL1", + accountId: "7", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + sitePhone: "", + contactEmail: "", + status: "Active", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }), + ); + }); + + it("keeps the modal open with an inline error when the Site Code already exists", async () => { + create.mockRejectedValue(duplicateCodeError()); + const toastError = vi.spyOn(toast, "error"); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + expect(await within(dialog).findByText("This site code already exists.")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: "Site Code" })).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(onClose).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("Cancel closes without calling the API", async () => { + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx index 60444e29..fa6df17e 100644 --- a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -37,6 +37,7 @@ function ContactsHarness({ state: "TX", zipCode: "70068", contactEmail: "jane@example.com", + accountId: "7", contacts: initialContacts, }, }); @@ -78,7 +79,7 @@ describe("LocationContactsFieldArray", () => { { withAuth: false }, ); - fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + fireEvent.click(screen.getByRole("button", { name: /add point of contact/i })); expect(nameInputs()).toHaveLength(2); expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx deleted file mode 100644 index 23b716a3..00000000 --- a/src/test/app/(protected)/locations/location-form-state.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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 | 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( - - } /> - , - { - 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(, { - route: "/locations/new", - routerProps: { initialEntries: ["/locations/new"] }, - withAuth: false, - }); - - expect(screen.getByLabelText(/^State/)).toHaveValue(""); - expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); - }); -}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx index e0258626..a903447f 100644 --- a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial): LocationListItem return { id: 1, name: "Dallas DC", + clientName: "Acme", + sitePhone: "", address: "3811 Distribution Dr", city: "Dallas", state: "TX", diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx index fb45e28c..19b54ad8 100644 --- a/src/test/app/(protected)/locations/locations-list-area.test.tsx +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -125,7 +125,7 @@ describe("LocationsListPage area wiring", () => { fireEvent.mouseDown(screen.getByLabelText("Area")); fireEvent.click(screen.getByRole("option", { name: "West" })); - fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + fireEvent.change(screen.getByPlaceholderText("Search sites..."), { target: { value: "dal" }, }); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx new file mode 100644 index 00000000..0b0cf469 --- /dev/null +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import type { Location, LocationListItem } from "@/domain/locations/types/location"; +import { renderWithProviders } from "@/test/test-utils"; + +const getList = vi.fn(); +const getById = vi.fn(); +const update = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + update: (...args: unknown[]) => update(...args), + create: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + contactEmail: "dal1@acme.test", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const chicago: Location = { + id: 2, + name: "CHI2", + clientName: "Globex", + accountId: 8, + address: "100 Main St", + city: "Chicago", + state: "IL", + contacts: [{ id: 41, name: "Carol", phone: "(312) 555-0100" }], +}; + +function listRow(location: Location): LocationListItem { + return { + id: location.id ?? "", + name: location.name ?? "", + clientName: location.clientName ?? "", + sitePhone: location.sitePhone ?? "", + address: location.address ?? "", + city: location.city ?? "", + state: location.state ?? "", + zipCode: location.zipCode ?? "", + phone: location.contacts?.[0]?.phone ?? "", + contact: location.contacts?.[0]?.name ?? "", + contactEmail: location.contactEmail ?? "", + status: "Active", + accountId: location.accountId ?? "", + contacts: location.contacts, + }; +} + +function panel(): HTMLElement { + return screen.getByRole("region", { name: "Site details" }); +} + +async function openSite(code: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: code })); + const region = panel(); + await within(region).findByRole("heading", { name: code }); + return region; +} + +describe("Sites page detail panel", () => { + beforeEach(() => { + getList.mockReset(); + getById.mockReset(); + update.mockReset(); + getList.mockResolvedValue({ + items: [listRow(dallas), listRow(chicago)], + totalCount: 2, + page: 1, + pageSize: 12, + }); + getById.mockImplementation((id: string) => + Promise.resolve(String(id) === "2" ? chicago : dallas), + ); + }); + + it("opens a row in view mode with the address linked to Google Maps and a map preview", async () => { + renderWithProviders(, { withAuth: false }); + + const region = await openSite("DAL1"); + + expect(within(region).getAllByText("Acme Logistics").length).toBeGreaterThan(0); + const address = within(region).getByRole("link", { name: "3811 Distribution Dr" }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByText("TX · Area: West")).toBeInTheDocument(); + expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); + expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); + expect(within(region).getByText(/Main: Jane/)).toBeInTheDocument(); + expect(within(region).getByText(/Contact 2: Bob/)).toBeInTheDocument(); + expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); + }); + + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { + update.mockResolvedValue({}); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + const siteCode = within(region).getByRole("textbox", { name: "Site Code" }); + expect(siteCode).toHaveValue("DAL1"); + expect(siteCode).toBeDisabled(); + const save = within(region).getByRole("button", { name: "Save changes" }); + expect(save).toBeDisabled(); + + getById.mockResolvedValue({ ...dallas, city: "Fort Worth" }); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Fort Worth" }, + }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + name: "DAL1", + city: "Fort Worth", + sitePhone: "(214) 555-0100", + zipCode: "75201", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }), + ); + expect(await within(region).findByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Fort Worth")).toBeInTheDocument(); + }); + + it("Cancel discards edits and returns to view without an API call", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Houston" }, + }); + fireEvent.click(within(region).getByRole("button", { name: "Cancel" })); + + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Dallas")).toBeInTheDocument(); + expect(within(region).queryByText("Houston")).not.toBeInTheDocument(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps the table usable: another row swaps the panel content in place", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + fireEvent.click(screen.getByRole("button", { name: "CHI2" })); + + await within(panel()).findByRole("heading", { name: "CHI2" }); + expect(panel()).toBe(region); + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getAllByText("Globex").length).toBeGreaterThan(0); + expect(within(region).queryByText("Acme Logistics")).not.toBeInTheDocument(); + }); + + it("Escape closes the panel", async () => { + renderWithProviders(, { withAuth: false }); + await openSite("DAL1"); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index 68c3fa51..7ebf6040 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -29,7 +29,7 @@ function StateSelectHarness({ zipCode: "70068", contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", - status: "Active", + accountId: "7", }, }); diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts index 52d27cc6..4547267b 100644 --- a/src/test/domain/locations/mappers/location-mapper.test.ts +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -56,16 +56,15 @@ describe("location contacts hydration", () => { }); }); -describe("mapLocationToBackend contacts payload", () => { - it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { +describe("mapLocationToBackend site payload", () => { + it("sends trimmed contacts in displayed order and names the first as the legacy contact", () => { const payload = mapLocationToBackend({ - name: "Dallas DC", + name: " dal1 ", address: "3811 Distribution Dr", city: "Dallas", state: "TX", zipCode: "70068", contactEmail: "jane@example.com", - status: "Active", contacts: [ { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, { name: "Bob", phone: "(421) 433-0033" }, @@ -77,18 +76,59 @@ describe("mapLocationToBackend contacts payload", () => { { name: "Bob", phone: "(421) 433-0033" }, ]); expect(payload.contact).toBe("Jane"); - expect(payload.phone).toBe("(421) 433-0032"); + expect(payload.name).toBe("dal1"); }); - it("keeps legacy contact/phone untouched when no contacts are provided", () => { + it("sends the Site Phone as phone, never the main contact's phone", () => { const payload = mapLocationToBackend({ - name: "Legacy DC", - contact: "Legacy Person", - phone: "555-0199", + sitePhone: " (214) 555-0100 ", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + const withoutSitePhone = mapLocationToBackend({ + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], }); - expect(payload.contact).toBe("Legacy Person"); - expect(payload.phone).toBe("555-0199"); - expect(payload.contacts).toEqual([]); + expect(payload.phone).toBe("(214) 555-0100"); + expect(withoutSitePhone.phone).toBe(""); + }); + + it("omits status, title and notes unless given, so an update keeps the stored values", () => { + const bare = mapLocationToBackend({ name: "DAL1", contacts: [] }); + const full = mapLocationToBackend({ + name: "DAL1", + status: "Active", + title: "Dallas DC", + notes: "Gate code 1234", + contacts: [], + }); + + expect(bare).not.toHaveProperty("status"); + expect(bare).not.toHaveProperty("title"); + expect(bare).not.toHaveProperty("notes"); + expect(full).toMatchObject({ status: "Active", title: "Dallas DC", notes: "Gate code 1234" }); + }); +}); + +describe("mapLocation site fields", () => { + it("reads client name, site phone, title and notes separately from the main contact", () => { + const detail = mapLocation({ + id: 9, + name: "DAL1", + title: "Dallas DC", + clientName: "Acme", + phone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + const row = mapLocationListItem({ id: 9, accountName: "Acme", phone: "(214) 555-0100" }); + + expect(detail).toMatchObject({ + clientName: "Acme", + sitePhone: "(214) 555-0100", + title: "Dallas DC", + notes: "Gate code 1234", + phone: "555-0143", + }); + expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" }); }); }); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts index ecf04b56..859a0592 100644 --- a/src/test/domain/locations/schemas/location-schema.test.ts +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -7,7 +7,8 @@ import { const validBase: LocationFormValues = { ...locationDefaultValues, - name: "Dallas DC", + name: "DAL1", + accountId: "7", address: "3811 Distribution Dr", city: "Dallas", state: "TX", @@ -46,8 +47,8 @@ describe("locationSchema contacts", () => { expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((issue) => issue.message); - expect(messages).toContain("Phone is required"); - expect(messages).toContain("Contact person is required"); + expect(messages).toContain("Contact phone is required"); + expect(messages).toContain("Contact name is required"); } }); @@ -63,3 +64,41 @@ describe("locationSchema contacts", () => { } }); }); + +describe("locationSchema site fields", () => { + const validSite: LocationFormValues = { + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }; + + function messagesFor(values: LocationFormValues): string[] { + const result = locationSchema.safeParse(values); + return result.success ? [] : result.error.issues.map((issue) => issue.message); + } + + it("requires Site Code, Client, Street Address, City, State and the first contact", () => { + const messages = messagesFor({ ...locationDefaultValues }); + + expect(messages).toEqual( + expect.arrayContaining([ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]), + ); + }); + + it("keeps Site Phone, Site Email and zip optional but checks the email format", () => { + expect( + locationSchema.safeParse({ ...validSite, sitePhone: "", contactEmail: "", zipCode: "" }) + .success, + ).toBe(true); + expect(messagesFor({ ...validSite, contactEmail: "not-an-email" })).toContain( + "Invalid email address", + ); + }); +});