Merge branch 'dev' into feat/SH-338-unscheduled-filter

This commit is contained in:
Alexandre Brandizzi 2026-09-15 19:03:38 -03:00 • committed by GitHub
commit 53d78db0ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2423 additions and 466 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -409,7 +409,16 @@ test.describe("Vendor directory prototype parity", () => {
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
await expect(tradeBadge).toHaveText("+1");
await tradeBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
await tradeBadge.focus();
await expect(tradeBadge).toBeFocused();
await tradeBadge.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await tradeBadge.press("Enter");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
@ -587,10 +596,16 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
// SH-271: one combined "Address (optional)" line became three required
// fields, and the hand-pasted Google Maps URL input is gone — the location
// is derived from the address now.
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
"1 Market St",
);
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
await expect(page.getByLabel("State (required)")).toHaveValue("MO");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
@ -643,9 +658,15 @@ test.describe("Vendor directory prototype parity", () => {
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
// SH-271: the separate "Open in Google Maps" row is gone — the Street
// Address itself is the link, built from the structured parts.
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
await expect(streetLink).toHaveAttribute("target", "_blank");
await expect(streetLink).toHaveAttribute(
"href",
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
);
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
@ -693,6 +714,12 @@ test.describe("Vendor directory prototype parity", () => {
).toBeEnabled();
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
// SH-271 made the structured address required. This test is about keyboard
// dismissal and free-text company creation, so the address is filled to
// leave those as the only things it is exercising.
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
await page.getByLabel("City (required)").fill("St. Louis");
await page.getByLabel("State (required)").fill("MO");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");

View file

@ -70,6 +70,13 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
// The vendor address map preview embeds maps.google.com in an iframe. Its
// content depends on the network, on timing, and on whatever Google serves
// that minute, none of which belongs in a test with "deterministic pixel
// regression" in its name. Aborting the request pins the frame to a blank
// box, so the shot measures our layout and nothing else.
await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort());
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());

106
package-lock.json generated
View file

@ -3137,14 +3137,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -3158,8 +3158,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.10",
"vitest": "4.1.10"
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -3168,16 +3168,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -3186,13 +3186,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -3213,9 +3213,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3226,13 +3226,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -3240,14 +3240,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -3256,9 +3256,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -3266,13 +3266,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -4378,9 +4378,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
@ -6942,19 +6942,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -6982,12 +6982,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View file

@ -40,7 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
city: roster.city,
state: roster.state,
zip: roster.zip,
googleMapsUrl: roster.googleMapsUrl,
// The deprecated stored Maps URL is intentionally not seeded: the form has
// no input for it, and carrying it would re-emit a legacy value on write.
notes: roster.notes,
technicians: roster.technicians.map((technician) => ({
id: technician.id,

View file

@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react";
import { useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
import {
Alert,
Box,
@ -22,6 +21,8 @@ import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import { Text } from "@/components/ui/text";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { mapsSearchUrl } from "@/lib/address/vendor-address";
import type {
VendorCompanyRoster,
VendorListItem,
@ -118,34 +119,43 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const address = [roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ");
// SH-271 replaced the separate "Open in Google Maps" row: the Street Address
// itself is the link now, and the location is derived from the structured
// parts rather than a stored URL.
const address = { street: roster.address, city: roster.city, state: roster.state };
const mapsUrl = mapsSearchUrl(address);
return (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Google Maps
Street Address
</Text>
{roster.googleMapsUrl ? (
{roster.address && mapsUrl ? (
<Link
href={roster.googleMapsUrl}
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
sx={{ "&:hover": { textDecoration: "underline" } }}
>
<LaunchIcon fontSize="small" />
Open in Google Maps
{roster.address}
</Link>
) : (
<Text variant="description">—</Text>
<Text variant="description">{roster.address || "—"}</Text>
)}
</Box>
<Stack direction="row" spacing={2}>
<Box sx={{ flex: 1 }}>
<Field label="City" value={roster.city} />
</Box>
<Box sx={{ flex: 1 }}>
<Field label="State" value={roster.state} />
</Box>
</Stack>
<AddressMapPreview address={address} />
</Stack>
);
}

View file

@ -1,5 +1,6 @@
import {
Controller,
useController,
useFieldArray,
useWatch,
type Control,
@ -21,6 +22,8 @@ import {
Typography,
} from "@mui/material";
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
import {
VENDOR_NOTES_MAX_LENGTH,
@ -135,6 +138,9 @@ function CompanyFields({
onClearSelectedCompany,
}: VendorRosterFormFieldsProps) {
const notes = useWatch({ control, name: "notes" }) ?? "";
const { field: streetField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
return (
<Box>
@ -148,50 +154,58 @@ function CompanyFields({
onClearSelectedCompany={onClearSelectedCompany}
/>
<VendorCompanyContactFields control={control} errors={errors} />
<Controller
control={control}
name="address"
render={({ field }) => (
<TextField
{...field}
label="Address (optional)"
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
fullWidth
/>
)}
<AddressAutocompleteField
value={streetField.value ?? ""}
onInputChange={streetField.onChange}
onSelect={(parts) => {
// One pick fills all three. City and State are written through
// their own controllers so react-hook-form marks them dirty and
// revalidates — a plain assignment would be discarded on save.
streetField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(parts.state);
}}
label="Street Address (required)"
required
error={Boolean(errors.address)}
helperText={
errors.address?.message ??
"Pick a suggestion to auto-fill City/State, or type your own."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
<Controller
control={control}
name="city"
render={({ field }) => <TextField {...field} label="City" fullWidth />}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...cityField}
value={cityField.value ?? ""}
label="City (required)"
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
<Controller
control={control}
name="state"
render={({ field }) => (
<TextField {...field} label="State" placeholder="e.g., MO" fullWidth />
)}
/>
<Controller
control={control}
name="zip"
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
<TextField
{...stateField}
value={stateField.value ?? ""}
label="State (required)"
placeholder="e.g., MO"
error={Boolean(errors.state)}
helperText={errors.state?.message}
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
</Stack>
<Controller
control={control}
name="googleMapsUrl"
render={({ field }) => (
<TextField
{...field}
label="Google Maps URL (optional)"
placeholder="https://maps.google.com/…"
error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message}
fullWidth
/>
)}
name="zip"
render={({ field }) => <TextField {...field} label="Zip" sx={{ display: "none" }} />}
/>
<AddressMapPreview
address={{
street: streetField.value ?? "",
city: cityField.value ?? "",
state: stateField.value ?? "",
}}
placeholderWhenIncomplete
/>
<Controller
control={control}

View file

@ -51,6 +51,12 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
function stopTradeBadgeKeyDown(event: KeyboardEvent): void {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
}
interface TruncatedTextProps {
value: string;
testId: string;
@ -129,10 +135,54 @@ interface VendorTableRowProps {
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTradeCell({
primaryTrade,
omittedTrades,
}: {
primaryTrade: string;
omittedTrades: string[];
}) {
const extraTrades = omittedTrades.length;
return (
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Tooltip title={omittedTrades.join(", ")} placement="top" arrow>
<Box
component="span"
tabIndex={0}
aria-label={`+${extraTrades} more ${extraTrades === 1 ? "trade" : "trades"}: ${omittedTrades.join(", ")}`}
data-testid="vendor-trade-badge"
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
onClick={stopPropagation}
onKeyDown={stopTradeBadgeKeyDown}
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
</Tooltip>
)}
</Stack>
)}
</TableCell>
);
}
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const omittedTrades = trades.slice(1);
const { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
@ -197,30 +247,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
)}
</Stack>
)}
</TableCell>
<VendorTradeCell primaryTrade={primaryTrade} omittedTrades={omittedTrades} />
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}

View file

@ -1,4 +1,5 @@
import { ExternalLink, Mail, MapPin, Phone, User } from "lucide-react";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
@ -122,6 +123,12 @@ export function VendorDialogCompanyCard({
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
{/*
The card holds one composed address line for both live and frozen
companies, so the preview is given the line directly rather than
structured parts; it is saved data either way.
*/}
<AddressMapPreview query={model.address} height={140} />
</div>
);
}

View file

@ -11,6 +11,13 @@ import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-ord
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
/**
* Height bound for the dialog surface. The company card grew with the map
* preview, so the dialog itself — not just the card — must cap its height and
* scroll internally or it outgrows short viewports.
*/
export const VENDOR_DIALOG_SURFACE_MAX_HEIGHT = "55vh";
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -56,12 +63,22 @@ export function VendorDialog({
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 560 },
style: {
borderRadius: 12,
maxWidth: 560,
maxHeight: VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
display: "flex",
flexDirection: "column",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogContent
className="p-6"
onClick={(e) => e.stopPropagation()}
style={{ overflowY: "auto", minHeight: 0 }}
>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Vendor
</DialogTitle>

View file

@ -0,0 +1,88 @@
import { Autocomplete, TextField } from "@mui/material";
import {
suggestAddresses,
type AddressParts,
type AddressSuggestion,
} from "@/lib/address/vendor-address";
type AddressAutocompleteFieldProps = {
value: string;
onInputChange: (value: string) => void;
/** Fired only when a candidate row is chosen, never while typing. */
onSelect: (parts: AddressParts) => void;
label?: string;
placeholder?: string;
required?: boolean;
error?: boolean;
helperText?: string;
disabled?: boolean;
};
/**
* Street Address input with mocked City/State completion.
*
* `freeSolo` is the whole point: typing without picking a row is a valid way to
* finish the field. The candidates are a convenience for filling City and State
* in one action, not a constraint on what the address may be.
*
* Shared rather than local to Vendors because Sites needs the identical control
* when it is built (SH-273).
*/
export function AddressAutocompleteField({
value,
onInputChange,
onSelect,
label = "Street Address",
placeholder = "Start typing the street address…",
required = false,
error = false,
helperText,
disabled = false,
}: AddressAutocompleteFieldProps) {
return (
<Autocomplete<AddressSuggestion, false, false, true>
freeSolo
disabled={disabled}
options={suggestAddresses(value)}
// The options are already derived from the input; letting MUI filter them
// again would drop every row whose label does not literally contain the
// typed text.
filterOptions={(options) => options}
inputValue={value}
onInputChange={(_event, next, reason) => {
// Genuine typing and the clear control update the street. MUI also
// fires this callback with reason "reset" after a selection, carrying
// the option's full "<street>, <city>, <state>" label — forwarding
// that would overwrite the just-picked street with the label.
if (reason !== "input" && reason !== "clear") return;
onInputChange(next);
}}
onChange={(_event, selected) => {
if (selected && typeof selected !== "string") {
onSelect({ street: selected.street, city: selected.city, state: selected.state });
return;
}
if (selected === null) onSelect({ street: "", city: "", state: "" });
}}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
renderInput={(params) => (
<TextField
{...params}
label={label}
placeholder={placeholder}
error={error}
helperText={helperText}
fullWidth
// Required is set on the input itself, never via MUI's `required`
// prop: this codebase spells the requirement in the label text and
// asserts that no generated asterisk renders anywhere.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },
}}
/>
)}
/>
);
}

View file

@ -0,0 +1,99 @@
import { Box } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
buildAddressQuery,
hasCompleteAddress,
mapsEmbedUrl,
type AddressParts,
} from "@/lib/address/vendor-address";
type AddressMapPreviewProps = {
/** Structured parts, as held by a form or a detail view. */
address?: Partial<AddressParts>;
/**
* An already-composed address line, for callers that hold saved data rather
* than live input — the Work Order vendor dialog gets one string from the
* vendor dropdown payload. Ignored when `address` is given.
*/
query?: string;
height?: number;
/**
* Render a compact accessible placeholder while the structured address is
* incomplete, instead of nothing. Meant for Add/Edit forms, where the empty
* state tells the dispatcher which parts the preview is still waiting on.
*/
placeholderWhenIncomplete?: boolean;
};
const INCOMPLETE_PLACEHOLDER_MESSAGE =
"Map preview appears once Street, City, and State are all filled.";
/**
* Small inline map for a structured address.
*
* Uses Google's keyless `output=embed` form deliberately: SH-271 asks for a
* preview with no API key, so there is no Maps JavaScript API, no Places
* request and no billing account behind this.
*
* Structured input renders nothing until Street, City and State are all
* present — a preview of a half-typed address points somewhere misleading,
* which is worse than no map. Add/Edit forms opt into a compact placeholder
* for that incomplete state; a pre-composed saved line is map-only and renders
* only when non-empty.
*/
export function AddressMapPreview({
address,
query: rawQuery,
height = 160,
placeholderWhenIncomplete = false,
}: AddressMapPreviewProps) {
// Structured input is held to the completeness rule; a pre-composed line is
// saved data and only has to be non-empty.
const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
const complete = address ? hasCompleteAddress(address) : Boolean(query);
if (!complete) {
if (!(address != null && placeholderWhenIncomplete)) return null;
return (
<Box
role="status"
sx={{
borderRadius: 1,
border: "1px dashed",
borderColor: "divider",
minHeight: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1.5,
py: 1,
}}
>
<Text variant="caption" tone="muted" sx={{ textAlign: "center" }}>
{INCOMPLETE_PLACEHOLDER_MESSAGE}
</Text>
</Box>
);
}
return (
<Box
sx={{
borderRadius: 1,
overflow: "hidden",
border: 1,
borderColor: "divider",
lineHeight: 0,
}}
>
<Box
component="iframe"
title={`Map of ${query}`}
src={mapsEmbedUrl({ street: query })}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
sx={{ display: "block", width: "100%", height, border: 0 }}
/>
</Box>
);
}

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

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

View file

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

View file

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

View file

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

View file

@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
// The stored Maps URL is deprecated: reads keep it for legacy records, but
// roster writes must not carry it back to the backend.
notes: readString(item, "notes", "Notes"),
technicians: Array.isArray(techniciansRaw)
? techniciansRaw.map(mapRosterTechnicianToBackend)

View file

@ -8,21 +8,6 @@ const preferredContactEnum = z.enum(
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
);
export function isAbsoluteHttpsUrl(value: string): boolean {
if (value.trim() === "") return true;
try {
const parsed = new URL(value);
return parsed.protocol === "https:";
} catch {
return false;
}
}
const httpsUrl = z
.string()
.trim()
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
export function isValidNorthAmericanPhone(value: string): boolean {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
@ -57,11 +42,15 @@ const baseCompanyFields = {
companyContact: z.never().optional(),
companyPhone: northAmericanPhone,
email: optionalEmail,
address: z.string(),
city: z.string(),
state: z.string(),
// SH-271: the single combined Address line became three structured parts,
// all required, and the stored Google Maps URL was retired from the form.
// `googleMapsUrl` stays on the read models for legacy records but is not a
// form value: loading it would validate and re-emit a deprecated field the
// user can no longer see or edit.
address: z.string().trim().min(1, "Street Address is required"),
city: z.string().trim().min(1, "City is required"),
state: z.string().trim().min(1, "State is required"),
zip: z.string(),
googleMapsUrl: httpsUrl,
notes: z
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
@ -117,7 +106,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [],
};

View file

@ -26,6 +26,9 @@ export interface SaveVendorCompanyRosterContext {
conflict?: unknown;
}
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
// so neither the additive companyFields patch nor the status-only diff may
// ever transmit it.
const COMPANY_FIELDS = [
"name",
"companyPhone",
@ -34,7 +37,6 @@ const COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View file

@ -0,0 +1,135 @@
/**
* Structured address helpers shared by Vendors and, when it is built, Sites.
*
* The suggestion list is deliberately MOCKED — SH-271 asks for a real-feeling
* autocomplete with no Google Places call and no API key. The algorithm,
* candidate pool and copy are ported from the approved prototype so dev and
* design agree on what a dispatcher sees.
*/
export type AddressParts = {
street: string;
city: string;
state: string;
};
export type AddressSuggestion = AddressParts & {
/** What the option row reads as: "<typed street>, <city>, <state>". */
label: string;
};
/** Minimum characters before any suggestion appears. */
export const ADDRESS_SUGGESTION_MIN_LENGTH = 3;
/** How many candidates the field offers at most. */
export const ADDRESS_SUGGESTION_LIMIT = 4;
/**
* Candidate cities, ported verbatim from the prototype.
*
* These are plausible distribution-centre metros rather than a geocoder: the
* point is that a dispatcher sees a short, stable list to pick from, not that
* the address is real.
*/
const SUGGESTION_CITIES: ReadonlyArray<Omit<AddressParts, "street">> = [
{ city: "Dallas", state: "TX" },
{ city: "Indianapolis", state: "IN" },
{ city: "Memphis", state: "TN" },
{ city: "St. Louis", state: "MO" },
{ city: "Orlando", state: "FL" },
{ city: "Austin", state: "TX" },
{ city: "Huntsville", state: "AL" },
{ city: "Rockford", state: "IL" },
{ city: "Columbus", state: "OH" },
{ city: "Tampa", state: "FL" },
{ city: "Cleveland", state: "OH" },
{ city: "Atlanta", state: "GA" },
{ city: "Chicago", state: "IL" },
{ city: "Phoenix", state: "AZ" },
{ city: "Portland", state: "OR" },
{ city: "Jersey City", state: "NJ" },
{ city: "Denver", state: "CO" },
{ city: "Miami", state: "FL" },
];
/**
* Deterministic index from a seed string.
*
* Deterministic on purpose: the same typed text must offer the same candidates
* every keystroke and every render, or the list reshuffles under the pointer
* while someone is trying to click a row.
*/
function seededIndex(seed: string, size: number): number {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return hash % size;
}
/**
* Up to four candidates for what the dispatcher has typed so far.
*
* The typed text is always kept as the street; only City and State are
* proposed. Selecting a candidate fills all three, and typing without selecting
* leaves plain free text — the field never locks the user out of their own
* input.
*/
export function suggestAddresses(input: string): AddressSuggestion[] {
const street = input.trim();
if (street.length < ADDRESS_SUGGESTION_MIN_LENGTH) return [];
const seen = new Set<string>();
const suggestions: AddressSuggestion[] = [];
for (
let attempt = 0;
suggestions.length < ADDRESS_SUGGESTION_LIMIT && attempt < SUGGESTION_CITIES.length;
attempt += 1
) {
const candidate =
SUGGESTION_CITIES[seededIndex(`${street}#${attempt}`, SUGGESTION_CITIES.length)];
if (!candidate) continue;
const key = `${candidate.city},${candidate.state}`;
if (seen.has(key)) continue;
seen.add(key);
suggestions.push({
label: `${street}, ${candidate.city}, ${candidate.state}`,
street,
city: candidate.city,
state: candidate.state,
});
}
return suggestions;
}
/** The single-line form Google is asked about. Empty when there is nothing to ask. */
export function buildAddressQuery(parts: Partial<AddressParts>): string {
return [parts.street, parts.city, parts.state]
.map((part) => part?.trim() ?? "")
.filter(Boolean)
.join(" ");
}
/** Link target for the clickable Street Address. Empty string when unmappable. */
export function mapsSearchUrl(parts: Partial<AddressParts>): string {
const query = buildAddressQuery(parts);
return query
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
: "";
}
/**
* Embed URL for the inline preview.
*
* `output=embed` is the keyless form: it renders in an iframe with no API key,
* no billing account and no Places request, which is what SH-271 requires.
*/
export function mapsEmbedUrl(parts: Partial<AddressParts>): string {
const query = buildAddressQuery(parts);
return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : "";
}
/** A preview is only meaningful once all three parts are present. */
export function hasCompleteAddress(parts: Partial<AddressParts>): boolean {
return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim());
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { FormEvent, ReactNode } from "react";
import { useController, useFieldArray } from "react-hook-form";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -133,7 +133,6 @@ function submitValues(
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "",
notes: "",
technicians,
};
@ -300,7 +299,6 @@ describe("useVendorRosterForm prototype defaults", () => {
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
@ -322,6 +320,46 @@ describe("useVendorRosterForm prototype defaults", () => {
});
});
describe("useVendorRosterForm legacy stored Maps URL", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("saves an edit of a legacy roster whose stored Maps URL is plain HTTP", async () => {
const legacyRoster: VendorCompanyRoster = {
...roster,
googleMapsUrl: "http://maps.example.test/legacy",
};
rosterQueryResult = {
data: legacyRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
};
const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
wrapper: makeWrapper(createClient()),
});
// Exercise the real resolver: while the form schema validated the hidden
// loaded URL, this submit never reached the save path.
await act(async () => {
await result.current.handleSubmit(result.current.submit)({
preventDefault: () => undefined,
} as unknown as FormEvent<HTMLFormElement>);
});
expect(saveMutate).toHaveBeenCalledTimes(1);
const input = saveMutate.mock.calls[0]?.[0] as {
mode: string;
values: VendorCompanyRosterFormValues;
};
expect(input.mode).toBe("update");
expect(input.values).not.toHaveProperty("googleMapsUrl");
});
});
describe("useVendorRosterForm technician removal", () => {
it("keeps a valid roster saveable after an existing technician is removed", async () => {
rosterQueryResult = {

View file

@ -51,9 +51,25 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
});
// SH-271 made the structured address required. These tests are about the
// phone-or-email rule, so the address is filled to keep the form's only
// outstanding problem the one under test.
fillRequiredAddress();
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
}
function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) {
fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), {
target: { value: street },
});
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
target: { value: city },
});
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
target: { value: state },
});
}
describe("VendorCreateModal validation", () => {
it("explains the company phone-or-email requirement after submission", async () => {
renderCreateModal();

View file

@ -49,9 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
// SH-271: Street/City/State are required, so a saved roster carries them.
// Tests that need the incomplete case blank them explicitly.
address: "6735 Trippel Rd",
city: "Theodore",
state: "AL",
zip: "",
googleMapsUrl: "",
notes: "",
@ -343,7 +345,9 @@ describe("VendorDetailDrawer design parity", () => {
expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument();
});
it("labels the Google Maps field below Address even when no URL is stored", () => {
it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => {
// SH-271 removed the "Open in Google Maps" row and the stored URL behind
// it; the location is derived from the structured address instead.
useVendorCompanyRoster.mockReturnValue(
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
);
@ -353,15 +357,37 @@ describe("VendorDetailDrawer design parity", () => {
withAuth: false,
});
expect(screen.getByText("Google Maps")).toBeInTheDocument();
expect(screen.getByText("Street Address")).toBeInTheDocument();
expect(screen.getByText("City")).toBeInTheDocument();
expect(screen.getByText("State")).toBeInTheDocument();
expect(screen.queryByText("Google Maps")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument();
});
it("links out to Google Maps when a URL is stored", () => {
it("makes the street address itself the link to Google Maps", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
);
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
expect(screen.getByRole("link", { name: "6735 Trippel Rd" })).toHaveAttribute(
"href",
"https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
);
});
it("renders no map preview and no link until the address is complete", () => {
// A preview built from half an address points somewhere misleading.
const roster = rosterWith([
{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 },
]);
roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy";
roster.data.address = "";
roster.data.city = "";
roster.data.state = "";
useVendorCompanyRoster.mockReturnValue(roster);
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
@ -369,10 +395,8 @@ describe("VendorDetailDrawer design parity", () => {
withAuth: false,
});
expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute(
"href",
"https://maps.google.com/?q=1+Industrial+Pkwy",
);
expect(screen.queryByRole("link", { name: /Trippel/ })).not.toBeInTheDocument();
expect(document.querySelector("iframe")).toBeNull();
});
it("puts the status opposite Total Jobs and drops the redundant Status label", () => {

View file

@ -0,0 +1,142 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { describe, expect, it, vi } from "vitest";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import {
emptyVendorCompanyRosterForm,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { suggestAddresses } from "@/lib/address/vendor-address";
import { renderWithProviders } from "@/test/test-utils";
const TYPED_STREET = "415 Murphy Rd";
const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0];
if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street");
function AddressFormHarness({
onValid,
}: {
onValid: (values: VendorCompanyRosterFormValues) => void;
}) {
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(onValid)}>
<VendorRosterFormFields control={control} errors={formState.errors} />
<button type="submit">Submit</button>
</form>
);
}
function fillCompanyBasics(): void {
fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), {
target: { value: "Gateway Plumbing" },
});
fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), {
target: { value: "3145550100" },
});
}
describe("VendorRosterFormFields address autocomplete", () => {
it("keeps the picked street when a suggestion whose label differs is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
await user.type(street, TYPED_STREET);
const listbox = await screen.findByRole("listbox");
const option = within(listbox)
.getAllByRole("option")
.find((candidate) => candidate.textContent === FIRST_SUGGESTION.label);
expect(option).toBeDefined();
expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street);
await user.click(option as HTMLElement);
await waitFor(() => {
expect(street).toHaveValue(FIRST_SUGGESTION.street);
});
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
FIRST_SUGGESTION.city,
);
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
FIRST_SUGGESTION.state,
);
});
it("submits the settled structured parts, not the option label", async () => {
const user = userEvent.setup();
const onValid = vi.fn();
renderWithProviders(<AddressFormHarness onValid={onValid} />, { withAuth: false });
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
await user.type(street, TYPED_STREET);
const listbox = await screen.findByRole("listbox");
await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
fillCompanyBasics();
await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues;
expect(values.address).toBe(FIRST_SUGGESTION.street);
expect(values.city).toBe(FIRST_SUGGESTION.city);
expect(values.state).toBe(FIRST_SUGGESTION.state);
});
it("clears every structured address part from the autocomplete clear control", async () => {
const user = userEvent.setup();
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
await user.type(street, TYPED_STREET);
const listbox = await screen.findByRole("listbox");
await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
FIRST_SUGGESTION.city,
);
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
FIRST_SUGGESTION.state,
);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(street).toHaveValue("");
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue("");
});
});
describe("VendorRosterFormFields map preview empty state", () => {
it("shows the accessible placeholder while incomplete, then the map once complete", async () => {
const user = userEvent.setup();
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i);
expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
await user.type(
screen.getByRole("combobox", { name: "Street Address (required)" }),
TYPED_STREET,
);
expect(screen.getByRole("status")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
target: { value: "Theodore" },
});
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
target: { value: "AL" },
});
expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});

View file

@ -244,6 +244,99 @@ describe("VendorsListPage", () => {
);
});
it("shows omitted trade names on the +N badge tooltip on hover and keyboard focus", async () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).toHaveTextContent("+2");
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
expect(badge).toHaveAttribute("aria-label", "+2 more trades: Water Heaters, Drain Cleaning");
await userEvent.hover(badge);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Water Heaters, Drain Cleaning");
expect(tooltip).not.toHaveTextContent(/Backflow/);
await userEvent.unhover(badge);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
fireEvent.keyDown(document, { key: "Tab" });
badge.focus();
expect(badge).toHaveFocus();
expect(await screen.findByRole("tooltip")).toHaveTextContent("Water Heaters, Drain Cleaning");
});
it("does not show the +N badge when the vendor has a single trade", () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.queryByTestId("vendor-trade-badge")).not.toBeInTheDocument();
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
});
it("does not open the vendor detail when the +N badge is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
await userEvent.click(badge);
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
badge.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Space}");
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
});
it("relies on the MUI tooltip instead of a native title attribute on the +N badge", () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).not.toHaveAttribute("title");
expect(badge).toHaveAttribute("tabIndex", "0");
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({

View file

@ -0,0 +1,90 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
VendorDialog,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
const LIVE_VENDOR: VendorDropdownItem = {
id: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "900 Live St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
};
describe("VendorDialogCompanyCard map preview queries", () => {
it("embeds the live vendor address", () => {
renderWithProviders(
<VendorDialogCompanyCard company="Gateway Plumbing" vendor={LIVE_VENDOR} />,
{ withAuth: false },
);
const frame = screen.getByTitle("Map of 900 Live St");
expect(frame).toHaveAttribute(
"src",
"https://www.google.com/maps?q=900%20Live%20St&output=embed",
);
});
it("embeds the frozen company's composed snapshot address, not the live one", () => {
renderWithProviders(
<VendorDialogCompanyCard
company="Frozen Vendor"
vendor={LIVE_VENDOR}
frozenCompany={{
name: "Frozen Vendor",
address: "200 Vendor Ave",
city: "Chicago",
state: "IL",
zip: "60602",
}}
/>,
{ withAuth: false },
);
const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602");
expect(frame).toHaveAttribute(
"src",
"https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed",
);
expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument();
});
});
describe("VendorDialog scroll boundary", () => {
it("bounds the dialog surface and scrolls internally", () => {
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
vendors={[LIVE_VENDOR]}
onSave={vi.fn()}
/>,
);
const surface = screen.getByRole("dialog");
expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh");
expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT);
expect(surface.style.display).toBe("flex");
const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root");
expect(content).not.toBeNull();
expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" });
});
});

View file

@ -0,0 +1,68 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { renderWithProviders } from "@/test/test-utils";
const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
function mapFrame() {
return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL");
}
describe("AddressMapPreview structured input", () => {
it("renders a compact accessible placeholder while the address is incomplete", () => {
renderWithProviders(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} placeholderWhenIncomplete />,
{ withAuth: false },
);
const placeholder = screen.getByRole("status");
expect(placeholder).toHaveTextContent(/street, city, and state/i);
expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
});
it("transitions from the placeholder to the iframe once the address completes", () => {
const { rerender } = renderWithProviders(
<AddressMapPreview
address={{ street: "6735 Trippel Rd", city: "Theodore" }}
placeholderWhenIncomplete
/>,
{ withAuth: false },
);
expect(screen.getByRole("status")).toBeInTheDocument();
rerender(<AddressMapPreview address={complete} placeholderWhenIncomplete />);
const frame = mapFrame();
expect(frame).toBeInTheDocument();
expect(frame).toHaveAttribute(
"src",
"https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("stays blank for incomplete input when the placeholder is not opted into", () => {
const { container } = renderWithProviders(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} />,
{ withAuth: false },
);
expect(container.querySelector("iframe")).toBeNull();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
describe("AddressMapPreview pre-composed query", () => {
it("renders the map only when the saved query is non-empty", () => {
const { container, rerender } = renderWithProviders(<AddressMapPreview query="900 Live St" />, {
withAuth: false,
});
expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument();
rerender(<AddressMapPreview query=" " />);
expect(container.querySelector("iframe")).toBeNull();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});

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

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

View file

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

View file

@ -66,7 +66,7 @@ describe("vendor roster mapper", () => {
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
googleMapsUrl: "https://maps.google.com/x",
googleMapsUrl: "http://maps.google.com/legacy",
tradeSpecialties: "Plumbing",
rowVersion: "rv-2",
technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }],
@ -82,6 +82,9 @@ describe("vendor roster mapper", () => {
expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }),
]);
expect(payload).not.toHaveProperty("tradeSpecialties");
// The stored Maps URL is deprecated: a legacy value reaching the mapper
// (from stale callers or old records) must not be emitted to the backend.
expect(payload).not.toHaveProperty("googleMapsUrl");
});
it("omits rowVersion from the create payload when absent", () => {

View file

@ -3,7 +3,6 @@ import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl,
VENDOR_NOTES_MAX_LENGTH,
vendorCompanyRosterSchema,
vendorCompanyRosterUpdateSchema,
@ -14,6 +13,11 @@ const baseCompany = {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
// SH-271 made the structured address required, so a company that is meant to
// parse cleanly has to carry one.
address: "6735 Trippel Rd",
city: "Theodore",
state: "AL",
};
describe("vendorCompanyRosterSchema", () => {
@ -56,9 +60,11 @@ describe("vendorCompanyRosterSchema", () => {
it("accepts a company with zero technicians when phone is present", () => {
const result = vendorCompanyRosterSchema.safeParse({
...emptyVendorCompanyRosterForm,
...baseCompany,
name: "Solo Co",
email: "",
companyPhone: "(314) 555-0100",
technicians: [],
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
@ -98,21 +104,26 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(true);
});
it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => {
it("rejects malformed emails and partial phones", () => {
const result = vendorCompanyRosterSchema.safeParse({
...baseCompany,
email: "not-an-email",
googleMapsUrl: "http://maps.google.com/x",
technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }],
});
expect(result.success).toBe(false);
});
it("rejects a maps URL that is a non-absolute https prefix string", () => {
expect(isAbsoluteHttpsUrl("https://")).toBe(false);
expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false);
expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true);
expect(isAbsoluteHttpsUrl("")).toBe(true);
it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => {
// Legacy records carry a stored googleMapsUrl — often plain HTTP. The form
// no longer holds the field, so leftover values loaded from old data must
// neither fail validation nor re-enter the parsed output.
const result = vendorCompanyRosterSchema.safeParse({
...baseCompany,
googleMapsUrl: "http://maps.google.com/legacy",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty("googleMapsUrl");
});
it("rejects an unknown preferred contact", () => {

View file

@ -74,7 +74,6 @@ function formValues(
city: roster.city,
state: roster.state,
zip: roster.zip,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: [newTechnician],
...overrides,

View file

@ -36,7 +36,6 @@ const values = {
city: roster.city,
state: roster.state,
zip: roster.zip,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: roster.technicians.map((technician) => ({
id: technician.id,

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
ADDRESS_SUGGESTION_LIMIT,
ADDRESS_SUGGESTION_MIN_LENGTH,
buildAddressQuery,
hasCompleteAddress,
mapsEmbedUrl,
mapsSearchUrl,
suggestAddresses,
} from "@/lib/address/vendor-address";
describe("suggestAddresses", () => {
it("offers nothing until the minimum length is reached", () => {
expect(suggestAddresses("")).toEqual([]);
expect(suggestAddresses("12")).toEqual([]);
expect(suggestAddresses(" 12 ")).toEqual([]);
expect(suggestAddresses("123").length).toBeGreaterThan(0);
});
it("caps the list so the dropdown stays scannable", () => {
expect(suggestAddresses("6735 Trippel Rd").length).toBeLessThanOrEqual(
ADDRESS_SUGGESTION_LIMIT,
);
});
it("keeps the typed text as the street and only proposes city and state", () => {
const [first] = suggestAddresses("415 Murphy Rd");
expect(first?.street).toBe("415 Murphy Rd");
expect(first?.city).toBeTruthy();
expect(first?.state).toBeTruthy();
expect(first?.label).toBe(`415 Murphy Rd, ${first?.city}, ${first?.state}`);
});
it("is stable for the same input", () => {
// The list must not reshuffle between renders, or a row moves out from
// under the pointer mid-click.
expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd"));
});
it("never repeats a city and state pair", () => {
const suggestions = suggestAddresses("3050 Gateway Commerce Dr");
const keys = suggestions.map((s) => `${s.city},${s.state}`);
expect(new Set(keys).size).toBe(keys.length);
});
it("trims the typed text before using it", () => {
const [first] = suggestAddresses(" 1715 Wall Triana Hwy ");
expect(first?.street).toBe("1715 Wall Triana Hwy");
});
it("exposes the minimum length it enforces", () => {
expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH))).not.toEqual([]);
expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH - 1))).toEqual([]);
});
});
describe("address queries and links", () => {
const full = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
it("joins the parts it has and skips the ones it does not", () => {
expect(buildAddressQuery(full)).toBe("6735 Trippel Rd Theodore AL");
expect(buildAddressQuery({ street: "", city: "Dallas", state: "TX" })).toBe("Dallas TX");
expect(buildAddressQuery({})).toBe("");
});
it("builds a search link and an embed link for the same address", () => {
expect(mapsSearchUrl(full)).toBe(
"https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
);
expect(mapsEmbedUrl(full)).toBe(
"https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
);
});
it("returns empty strings rather than a link to nowhere", () => {
// An href of "" renders as no link; a link to a bare maps search would look
// like an address we do not have.
expect(mapsSearchUrl({})).toBe("");
expect(mapsEmbedUrl({ street: " " })).toBe("");
});
it("needs all three parts before a preview is meaningful", () => {
expect(hasCompleteAddress(full)).toBe(true);
expect(hasCompleteAddress({ street: "6735 Trippel Rd", city: "Theodore", state: " " })).toBe(
false,
);
expect(hasCompleteAddress({ street: "6735 Trippel Rd" })).toBe(false);
});
});