mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge branch 'dev' into feat/SH-338-unscheduled-filter
This commit is contained in:
commit
53d78db0ce
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 |
41
e2e/vendors/vendors.spec.ts
vendored
41
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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");
|
||||
|
|
|
|||
7
e2e/vendors/vendors.visual.spec.ts
vendored
7
e2e/vendors/vendors.visual.spec.ts
vendored
|
|
@ -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
106
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
22
src/app/(protected)/locations/_components/area-facet.tsx
Normal file
22
src/app/(protected)/locations/_components/area-facet.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import { AREA_ZONES, type AreaZone } from "@/data/areas";
|
||||
|
||||
interface AreaFacetProps {
|
||||
value: AreaZone[];
|
||||
onChange: (zones: AreaZone[]) => void;
|
||||
}
|
||||
|
||||
/** Multi-select area facet; an empty selection means no area filtering. */
|
||||
export function AreaFacet({ value, onChange }: AreaFacetProps) {
|
||||
return (
|
||||
<Autocomplete<AreaZone, true, false, false>
|
||||
multiple
|
||||
size="small"
|
||||
className="min-w-[200px]"
|
||||
options={AREA_ZONES}
|
||||
value={value}
|
||||
onChange={(_event, zones) => onChange(zones)}
|
||||
renderInput={(params) => <TextField {...params} label="Area" placeholder="All areas" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||
|
||||
interface LocationDeleteDialogProps {
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
target: LocationListItem | null;
|
||||
}
|
||||
|
||||
export function LocationDeleteDialog({
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
target,
|
||||
}: LocationDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={Boolean(target)} onClose={onClose}>
|
||||
<DialogTitle>Delete Location</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{target?.name}"? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
|
||||
{isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
|
||||
import {
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
import { StateSelect } from "./state-select";
|
||||
|
||||
interface SharedFieldsProps {
|
||||
control: Control<LocationFormValues>;
|
||||
errors: FieldErrors<LocationFormValues>;
|
||||
register: UseFormRegister<LocationFormValues>;
|
||||
}
|
||||
|
||||
interface LocationDetailsFieldsProps extends SharedFieldsProps {
|
||||
accountOptions: AccountListItem[];
|
||||
isLoadingAccounts: boolean;
|
||||
onAccountSearchChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LocationDetailsFields({
|
||||
accountOptions,
|
||||
control,
|
||||
errors,
|
||||
isLoadingAccounts,
|
||||
onAccountSearchChange,
|
||||
register,
|
||||
}: LocationDetailsFieldsProps) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Location Information
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("name")}
|
||||
label="Name"
|
||||
required
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
loading={isLoadingAccounts}
|
||||
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
||||
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
onAccountSearchChange(value);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
{...register("address")}
|
||||
label="Address"
|
||||
required
|
||||
error={Boolean(errors.address)}
|
||||
helperText={errors.address?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("city")}
|
||||
label="City"
|
||||
required
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<StateSelect control={control} error={errors.state} />
|
||||
<TextField
|
||||
{...register("zipCode")}
|
||||
label="Zip Code"
|
||||
required
|
||||
error={Boolean(errors.zipCode)}
|
||||
helperText={errors.zipCode?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Contact Information
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("phone")}
|
||||
label="Phone"
|
||||
required
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("contact")}
|
||||
label="Contact Person"
|
||||
required
|
||||
error={Boolean(errors.contact)}
|
||||
helperText={errors.contact?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("contactEmail")}
|
||||
label="Contact Email"
|
||||
type="email"
|
||||
required
|
||||
error={Boolean(errors.contactEmail)}
|
||||
helperText={errors.contactEmail?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required error={Boolean(errors.status)}>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
||||
{LOCATION_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +1,20 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||
import { normalizeStateCode } from "@/data/areas";
|
||||
import {
|
||||
locationDefaultValues,
|
||||
locationSchema,
|
||||
type LocationFormValues,
|
||||
} from "@/domain/locations/schemas/location-schema";
|
||||
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
|
||||
|
||||
export default function LocationFormPage() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -67,7 +53,7 @@ export default function LocationFormPage() {
|
|||
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||
address: location.address ?? "",
|
||||
city: location.city ?? "",
|
||||
state: (location.state as LocationFormValues["state"]) || "IL",
|
||||
state: normalizeStateCode(location.state) ?? "",
|
||||
zipCode: location.zipCode ?? "",
|
||||
phone: location.phone ?? "",
|
||||
contact: location.contact ?? "",
|
||||
|
|
@ -121,141 +107,15 @@ export default function LocationFormPage() {
|
|||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Location Information
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("name")}
|
||||
label="Name"
|
||||
required
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
loading={isLoadingAccounts}
|
||||
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
String(option.id) === String(value.id)
|
||||
}
|
||||
value={
|
||||
accountOptions.find((account) => String(account.id) === field.value) ?? null
|
||||
}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
setAccountSearch(value);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
{...register("address")}
|
||||
label="Address"
|
||||
required
|
||||
error={Boolean(errors.address)}
|
||||
helperText={errors.address?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("city")}
|
||||
label="City"
|
||||
required
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="state"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required error={Boolean(errors.state)}>
|
||||
<InputLabel id="state-label">State</InputLabel>
|
||||
<Select {...field} labelId="state-label" label="State" value={field.value}>
|
||||
{LOCATION_STATE_OPTIONS.map((state) => (
|
||||
<MenuItem key={state} value={state}>
|
||||
{state}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("zipCode")}
|
||||
label="Zip Code"
|
||||
required
|
||||
error={Boolean(errors.zipCode)}
|
||||
helperText={errors.zipCode?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Contact Information
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("phone")}
|
||||
label="Phone"
|
||||
required
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("contact")}
|
||||
label="Contact Person"
|
||||
required
|
||||
error={Boolean(errors.contact)}
|
||||
helperText={errors.contact?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("contactEmail")}
|
||||
label="Contact Email"
|
||||
type="email"
|
||||
required
|
||||
error={Boolean(errors.contactEmail)}
|
||||
helperText={errors.contactEmail?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required error={Boolean(errors.status)}>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
||||
{LOCATION_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<LocationDetailsFields
|
||||
accountOptions={accountOptions}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isLoadingAccounts={isLoadingAccounts}
|
||||
onAccountSearchChange={setAccountSearch}
|
||||
register={register}
|
||||
/>
|
||||
<LocationContactFields control={control} errors={errors} register={register} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
|
|
|
|||
102
src/app/(protected)/locations/_components/locations-table.tsx
Normal file
102
src/app/(protected)/locations/_components/locations-table.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||
import { StateAreaCell } from "./state-area-cell";
|
||||
|
||||
interface LocationsTableProps {
|
||||
isLoading: boolean;
|
||||
onDelete: (row: LocationListItem) => void;
|
||||
onEdit: (row: LocationListItem) => void;
|
||||
rows: LocationListItem[];
|
||||
}
|
||||
|
||||
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Location Name</TableCell>
|
||||
<TableCell className="font-semibold">Address</TableCell>
|
||||
<TableCell className="font-semibold">City</TableCell>
|
||||
<TableCell className="font-semibold">State</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Contact</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => onEdit(row)}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.address || "—"}</TableCell>
|
||||
<TableCell>{row.city || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<StateAreaCell state={row.state} />
|
||||
</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell>{row.contact || "—"}</TableCell>
|
||||
<TableCell>{row.status || "—"}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete location"
|
||||
onClick={() => onDelete(row)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Stack, Typography } from "@mui/material";
|
||||
import { getAreaForState, normalizeStateCode } from "@/data/areas";
|
||||
|
||||
/** State abbreviation with the derived area as secondary text. */
|
||||
export function StateAreaCell({ state }: { state?: string | null }) {
|
||||
const code = normalizeStateCode(state);
|
||||
const area = code === null ? null : getAreaForState(code);
|
||||
|
||||
if (code === null) {
|
||||
return (
|
||||
<Typography variant="body2" component="span">
|
||||
{state?.trim() || "—"}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Typography variant="body2" component="span">
|
||||
{code}
|
||||
</Typography>
|
||||
{area !== null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{area}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
49
src/app/(protected)/locations/_components/state-select.tsx
Normal file
49
src/app/(protected)/locations/_components/state-select.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Controller, type Control, type FieldError } from "react-hook-form";
|
||||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import {
|
||||
US_STATE_CODES,
|
||||
formatStateOptionLabel,
|
||||
getAreaForState,
|
||||
type StateCode,
|
||||
} from "@/data/areas";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
|
||||
interface StateSelectProps {
|
||||
control: Control<LocationFormValues>;
|
||||
error?: FieldError;
|
||||
}
|
||||
|
||||
/** Searchable, non-free-text state picker with derived area helper text. */
|
||||
export function StateSelect({ control, error }: StateSelectProps) {
|
||||
return (
|
||||
<Controller
|
||||
name="state"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const selected: StateCode | null =
|
||||
US_STATE_CODES.find((code) => code === field.value) ?? null;
|
||||
const area = selected === null ? null : getAreaForState(selected);
|
||||
const helperText = error?.message ?? (area === null ? undefined : `Area: ${area}`);
|
||||
|
||||
return (
|
||||
<Autocomplete<StateCode>
|
||||
options={US_STATE_CODES}
|
||||
value={selected}
|
||||
onChange={(_event, option) => field.onChange(option ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
getOptionLabel={formatStateOptionLabel}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="State"
|
||||
required
|
||||
error={Boolean(error)}
|
||||
helperText={helperText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +1,45 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||
import { AreaFacet } from "./_components/area-facet";
|
||||
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
||||
import { LocationsTable } from "./_components/locations-table";
|
||||
|
||||
export default function LocationsListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const deleteLocation = useDeleteLocation();
|
||||
|
||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
states: stateCodes.length > 0 ? stateCodes : undefined,
|
||||
}),
|
||||
[debouncedSearch, page, pageSize],
|
||||
[debouncedSearch, page, pageSize, stateCodes],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
|
||||
|
|
@ -103,6 +95,13 @@ export default function LocationsListPage() {
|
|||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<AreaFacet
|
||||
value={selectedAreas}
|
||||
onChange={(zones) => {
|
||||
setSelectedAreas(zones);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -123,86 +122,12 @@ export default function LocationsListPage() {
|
|||
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Location Name</TableCell>
|
||||
<TableCell className="font-semibold">Address</TableCell>
|
||||
<TableCell className="font-semibold">City</TableCell>
|
||||
<TableCell className="font-semibold">State</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Contact</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No locations found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.address || "—"}</TableCell>
|
||||
<TableCell>{row.city || "—"}</TableCell>
|
||||
<TableCell>{row.state || "—"}</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell>{row.contact || "—"}</TableCell>
|
||||
<TableCell>{row.status || "—"}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Edit location"
|
||||
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete location"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<LocationsTable
|
||||
isLoading={isLoading}
|
||||
rows={rows}
|
||||
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -215,28 +140,12 @@ export default function LocationsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Location</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteLocation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteLocation.isPending}
|
||||
>
|
||||
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<LocationDeleteDialog
|
||||
target={deleteTarget}
|
||||
isPending={deleteLocation.isPending}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
|
|||
"city",
|
||||
"state",
|
||||
"zip",
|
||||
"googleMapsUrl",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 || "—"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
88
src/components/common/address-autocomplete-field.tsx
Normal file
88
src/components/common/address-autocomplete-field.tsx
Normal 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 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
99
src/components/common/address-map-preview.tsx
Normal file
99
src/components/common/address-map-preview.tsx
Normal 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
170
src/data/areas.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Canonical source of truth for US states and their Seahaven area zone
|
||||
* mapping. Exactly the 50 states — no DC and no territories.
|
||||
* Area is always derived from the state code and never persisted.
|
||||
*/
|
||||
export const AREA_ZONES = ["East", "Central", "West", "California"] as const;
|
||||
|
||||
export type AreaZone = (typeof AREA_ZONES)[number];
|
||||
|
||||
export const AREA_ZONE_STATE_CODES = {
|
||||
East: [
|
||||
"CT",
|
||||
"DE",
|
||||
"GA",
|
||||
"MA",
|
||||
"MD",
|
||||
"ME",
|
||||
"NC",
|
||||
"NH",
|
||||
"NJ",
|
||||
"NY",
|
||||
"PA",
|
||||
"RI",
|
||||
"SC",
|
||||
"VA",
|
||||
"VT",
|
||||
"WV",
|
||||
],
|
||||
Central: [
|
||||
"AL",
|
||||
"AR",
|
||||
"FL",
|
||||
"IA",
|
||||
"IL",
|
||||
"IN",
|
||||
"KS",
|
||||
"KY",
|
||||
"LA",
|
||||
"MI",
|
||||
"MN",
|
||||
"MO",
|
||||
"MS",
|
||||
"ND",
|
||||
"NE",
|
||||
"OH",
|
||||
"OK",
|
||||
"SD",
|
||||
"TN",
|
||||
"WI",
|
||||
],
|
||||
West: ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"],
|
||||
California: ["CA"],
|
||||
} as const satisfies Record<AreaZone, readonly string[]>;
|
||||
|
||||
export type StateCode = (typeof AREA_ZONE_STATE_CODES)[AreaZone][number];
|
||||
|
||||
/** All 50 state codes in canonical order: East, Central, West, California. */
|
||||
export const US_STATE_CODES = [
|
||||
...AREA_ZONE_STATE_CODES.East,
|
||||
...AREA_ZONE_STATE_CODES.Central,
|
||||
...AREA_ZONE_STATE_CODES.West,
|
||||
...AREA_ZONE_STATE_CODES.California,
|
||||
] as [StateCode, ...StateCode[]];
|
||||
|
||||
const STATE_NAMES = {
|
||||
AL: "Alabama",
|
||||
AK: "Alaska",
|
||||
AZ: "Arizona",
|
||||
AR: "Arkansas",
|
||||
CA: "California",
|
||||
CO: "Colorado",
|
||||
CT: "Connecticut",
|
||||
DE: "Delaware",
|
||||
FL: "Florida",
|
||||
GA: "Georgia",
|
||||
HI: "Hawaii",
|
||||
ID: "Idaho",
|
||||
IL: "Illinois",
|
||||
IN: "Indiana",
|
||||
IA: "Iowa",
|
||||
KS: "Kansas",
|
||||
KY: "Kentucky",
|
||||
LA: "Louisiana",
|
||||
ME: "Maine",
|
||||
MD: "Maryland",
|
||||
MA: "Massachusetts",
|
||||
MI: "Michigan",
|
||||
MN: "Minnesota",
|
||||
MS: "Mississippi",
|
||||
MO: "Missouri",
|
||||
MT: "Montana",
|
||||
NE: "Nebraska",
|
||||
NV: "Nevada",
|
||||
NH: "New Hampshire",
|
||||
NJ: "New Jersey",
|
||||
NM: "New Mexico",
|
||||
NY: "New York",
|
||||
NC: "North Carolina",
|
||||
ND: "North Dakota",
|
||||
OH: "Ohio",
|
||||
OK: "Oklahoma",
|
||||
OR: "Oregon",
|
||||
PA: "Pennsylvania",
|
||||
RI: "Rhode Island",
|
||||
SC: "South Carolina",
|
||||
SD: "South Dakota",
|
||||
TN: "Tennessee",
|
||||
TX: "Texas",
|
||||
UT: "Utah",
|
||||
VT: "Vermont",
|
||||
VA: "Virginia",
|
||||
WA: "Washington",
|
||||
WV: "West Virginia",
|
||||
WI: "Wisconsin",
|
||||
WY: "Wyoming",
|
||||
} as const satisfies Record<StateCode, string>;
|
||||
|
||||
export interface StateAreaEntry {
|
||||
code: StateCode;
|
||||
name: string;
|
||||
zone: AreaZone;
|
||||
}
|
||||
|
||||
/** All 50 states with postal code, full name, and their single area zone. */
|
||||
export const STATE_AREA_ENTRIES: readonly StateAreaEntry[] = AREA_ZONES.flatMap((zone) =>
|
||||
AREA_ZONE_STATE_CODES[zone].map((code) => ({ code, name: STATE_NAMES[code], zone })),
|
||||
);
|
||||
|
||||
const STATE_CODE_SET = new Set<string>(US_STATE_CODES);
|
||||
|
||||
const STATE_NAME_TO_CODE = new Map<string, StateCode>(
|
||||
STATE_AREA_ENTRIES.map((entry) => [entry.name.toLowerCase(), entry.code]),
|
||||
);
|
||||
|
||||
const STATE_ZONE_BY_CODE = Object.fromEntries(
|
||||
STATE_AREA_ENTRIES.map((entry) => [entry.code, entry.zone]),
|
||||
) as Record<StateCode, AreaZone>;
|
||||
|
||||
/** Autocomplete option label: `Texas (TX)`. */
|
||||
export function formatStateOptionLabel(code: StateCode): string {
|
||||
return `${STATE_NAMES[code]} (${code})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes legacy API state values to the canonical two-letter code.
|
||||
* Accepts codes in any case and full state names (any case); otherwise null.
|
||||
*/
|
||||
export function normalizeStateCode(value: string | null | undefined): StateCode | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
const upper = trimmed.toUpperCase();
|
||||
if (STATE_CODE_SET.has(upper)) {
|
||||
return upper as StateCode;
|
||||
}
|
||||
return STATE_NAME_TO_CODE.get(trimmed.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
/** Derives the area zone for a state code or legacy full-name value. */
|
||||
export function getAreaForState(value: string | null | undefined): AreaZone | null {
|
||||
const code = normalizeStateCode(value);
|
||||
return code === null ? null : STATE_ZONE_BY_CODE[code];
|
||||
}
|
||||
|
||||
/** Expands selected area zones to their state codes in canonical order. */
|
||||
export function expandAreasToStateCodes(areas: readonly AreaZone[]): StateCode[] {
|
||||
const selected = new Set<AreaZone>(areas);
|
||||
return US_STATE_CODES.filter((code) => selected.has(STATE_ZONE_BY_CODE[code]));
|
||||
}
|
||||
|
|
@ -15,11 +15,17 @@ import type {
|
|||
} from "@/domain/locations/types/location";
|
||||
|
||||
function buildSearchParams(params: LocationsListParams): Record<string, string | number> {
|
||||
return {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
|
||||
if (params.states && params.states.length > 0) {
|
||||
searchParams.states = params.states.join(",");
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export const locationsApi = {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
import { US_STATE_CODES } from "@/data/areas";
|
||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||
|
||||
export const locationSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
accountId: z.string().optional(),
|
||||
address: z.string().min(1, "Address is required"),
|
||||
city: z.string().min(1, "City is required"),
|
||||
state: z.enum(LOCATION_STATE_OPTIONS, { message: "State is required" }),
|
||||
state: z
|
||||
.union([z.enum(US_STATE_CODES), z.literal("")])
|
||||
.refine((state): boolean => state !== "", { message: "State is required" }),
|
||||
zipCode: z.string().min(1, "Zip code is required"),
|
||||
phone: z.string().min(1, "Phone is required"),
|
||||
contact: z.string().min(1, "Contact person is required"),
|
||||
|
|
@ -21,7 +24,7 @@ export const locationDefaultValues: LocationFormValues = {
|
|||
accountId: "",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "IL",
|
||||
state: "",
|
||||
zipCode: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { StateCode } from "@/data/areas";
|
||||
|
||||
export interface LocationListItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
|
|
@ -38,6 +40,8 @@ export interface LocationsListParams {
|
|||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** State codes to filter by (area expansion); omitted from the query when empty. */
|
||||
states?: StateCode[];
|
||||
}
|
||||
|
||||
export interface LocationsListResult {
|
||||
|
|
@ -47,6 +51,4 @@ export interface LocationsListResult {
|
|||
pageSize: number;
|
||||
}
|
||||
|
||||
export const LOCATION_STATE_OPTIONS = ["IL", "IN", "MI", "OH", "WI"] as const;
|
||||
|
||||
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
135
src/lib/address/vendor-address.ts
Normal file
135
src/lib/address/vendor-address.ts
Normal 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());
|
||||
}
|
||||
103
src/test/app/(protected)/locations/location-form-state.test.tsx
Normal file
103
src/test/app/(protected)/locations/location-form-state.test.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import LocationFormPage from "@/app/(protected)/locations/_components/location-form";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const useLocationDetail = vi.fn();
|
||||
const useAccountsList = vi.fn();
|
||||
const useCreateLocation = vi.fn();
|
||||
const useUpdateLocation = vi.fn();
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: (...args: unknown[]) => useLocationDetail(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
|
||||
useAccountsList: (...args: unknown[]) => useAccountsList(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-create-location", () => ({
|
||||
useCreateLocation: (...args: unknown[]) => useCreateLocation(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-update-location", () => ({
|
||||
useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args),
|
||||
}));
|
||||
|
||||
function mockHooks(location: Record<string, unknown> | null) {
|
||||
useLocationDetail.mockReturnValue({
|
||||
data: location,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useAccountsList.mockReturnValue({
|
||||
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
|
||||
isLoading: false,
|
||||
});
|
||||
useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
|
||||
useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
|
||||
}
|
||||
|
||||
function renderEditPage(locationId: string) {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
|
||||
</Routes>,
|
||||
{
|
||||
route: `/locations/edit/${locationId}`,
|
||||
routerProps: { initialEntries: [`/locations/edit/${locationId}`] },
|
||||
withAuth: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("LocationFormPage state field", () => {
|
||||
it("normalizes legacy API state values for edit", () => {
|
||||
mockHooks({
|
||||
id: "1",
|
||||
name: "Dallas DC",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "texas",
|
||||
zipCode: "70068",
|
||||
phone: "(421) 433-0032",
|
||||
contact: "Jane",
|
||||
contactEmail: "jane@example.com",
|
||||
status: "Active",
|
||||
accountId: "7",
|
||||
});
|
||||
|
||||
renderEditPage("1");
|
||||
|
||||
const stateInput = screen.getByLabelText(/^State/);
|
||||
expect(stateInput).toHaveValue("Texas (TX)");
|
||||
expect(screen.getByText("Area: West")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("normalizes code values and keeps their derived area", () => {
|
||||
mockHooks({
|
||||
id: "2",
|
||||
state: "IL",
|
||||
status: "Active",
|
||||
});
|
||||
|
||||
renderEditPage("2");
|
||||
|
||||
expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)");
|
||||
expect(screen.getByText("Area: Central")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts empty in create mode with no implicit IL default", () => {
|
||||
mockHooks(null);
|
||||
|
||||
renderWithProviders(<LocationFormPage />, {
|
||||
route: "/locations/new",
|
||||
routerProps: { initialEntries: ["/locations/new"] },
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/^State/)).toHaveValue("");
|
||||
expect(screen.queryByText(/Area: /)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
139
src/test/app/(protected)/locations/locations-list-area.test.tsx
Normal file
139
src/test/app/(protected)/locations/locations-list-area.test.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import LocationsListPage from "@/app/(protected)/locations/index";
|
||||
import { expandAreasToStateCodes } from "@/data/areas";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const useLocationsList = vi.fn();
|
||||
const useDeleteLocation = vi.fn();
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-locations-list", () => ({
|
||||
useLocationsList: (...args: unknown[]) => useLocationsList(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-delete-location", () => ({
|
||||
useDeleteLocation: (...args: unknown[]) => useDeleteLocation(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Dallas DC",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "70068",
|
||||
phone: "(421) 433-0032",
|
||||
contact: "Jane",
|
||||
contactEmail: "jane@example.com",
|
||||
status: "Active",
|
||||
accountId: 7,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Chicago HQ",
|
||||
address: "100 Main St",
|
||||
city: "Chicago",
|
||||
state: "Illinois",
|
||||
zipCode: "60601",
|
||||
phone: "(312) 555-0100",
|
||||
contact: "Bob",
|
||||
contactEmail: "bob@example.com",
|
||||
status: "Active",
|
||||
accountId: 8,
|
||||
},
|
||||
];
|
||||
|
||||
function lastListParams() {
|
||||
const calls = useLocationsList.mock.calls;
|
||||
return calls[calls.length - 1][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function renderListPage() {
|
||||
useDeleteLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
|
||||
useLocationsList.mockReturnValue({
|
||||
data: { items: rows, totalCount: 30, page: 1, pageSize: 12 },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return renderWithProviders(<LocationsListPage />, { route: "/locations", withAuth: false });
|
||||
}
|
||||
|
||||
describe("LocationsListPage area wiring", () => {
|
||||
it("shows the state abbreviation with the area as secondary text", () => {
|
||||
renderListPage();
|
||||
|
||||
const table = screen.getByRole("table");
|
||||
expect(within(table).getByText("TX")).toBeInTheDocument();
|
||||
expect(within(table).getByText("West")).toBeInTheDocument();
|
||||
expect(within(table).getByText("IL")).toBeInTheDocument();
|
||||
expect(within(table).getByText("Central")).toBeInTheDocument();
|
||||
expect(within(table).queryByText("Illinois")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends no states filter while the area facet is empty", () => {
|
||||
renderListPage();
|
||||
|
||||
expect(lastListParams()).toEqual({ search: "", page: 1, pageSize: 12 });
|
||||
});
|
||||
|
||||
it("offers the four zones in canonical order", () => {
|
||||
renderListPage();
|
||||
|
||||
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
||||
|
||||
const options = screen.getAllByRole("option").map((option) => option.textContent ?? "");
|
||||
expect(options).toEqual(["East", "Central", "West", "California"]);
|
||||
});
|
||||
|
||||
it("expands selected areas to state codes and resets the page", () => {
|
||||
renderListPage();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Go to next page" }));
|
||||
expect(lastListParams()).toMatchObject({ page: 2 });
|
||||
|
||||
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "East" }));
|
||||
|
||||
expect(lastListParams()).toEqual({
|
||||
search: "",
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
states: expandAreasToStateCodes(["East"]),
|
||||
});
|
||||
expect(lastListParams().states).toHaveLength(16);
|
||||
|
||||
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "California" }));
|
||||
|
||||
expect(lastListParams()).toEqual({
|
||||
search: "",
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
states: expandAreasToStateCodes(["East", "California"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves search filtering alongside the area facet", () => {
|
||||
renderListPage();
|
||||
|
||||
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "West" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search locations..."), {
|
||||
target: { value: "dal" },
|
||||
});
|
||||
|
||||
expect(lastListParams()).toEqual({
|
||||
search: "dal",
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
states: expandAreasToStateCodes(["West"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/test/app/(protected)/locations/state-select.test.tsx
Normal file
137
src/test/app/(protected)/locations/state-select.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StateSelect } from "@/app/(protected)/locations/_components/state-select";
|
||||
import {
|
||||
locationDefaultValues,
|
||||
locationSchema,
|
||||
type LocationFormValues,
|
||||
} from "@/domain/locations/schemas/location-schema";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function StateSelectHarness({
|
||||
onSubmit = vi.fn(),
|
||||
}: {
|
||||
onSubmit?: (values: LocationFormValues) => void;
|
||||
}) {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: {
|
||||
...locationDefaultValues,
|
||||
name: "Dallas DC",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
zipCode: "70068",
|
||||
phone: "(421) 433-0032",
|
||||
contact: "Jane",
|
||||
contactEmail: "jane@example.com",
|
||||
status: "Active",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<StateSelect control={control} error={errors.state} />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function openOptions() {
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.mouseDown(input);
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("StateSelect", () => {
|
||||
it("starts empty with no implicit default state", () => {
|
||||
renderWithProviders(<StateSelectHarness />, { withAuth: false });
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("");
|
||||
expect(screen.queryByText("Area: ")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows searchable Name (CODE) options and no free-text entries", async () => {
|
||||
renderWithProviders(<StateSelectHarness />, { withAuth: false });
|
||||
const input = openOptions();
|
||||
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options).toHaveLength(50);
|
||||
expect(screen.getByRole("option", { name: "Texas (TX)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "California (CA)" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "mass" } });
|
||||
await screen.findAllByRole("option");
|
||||
expect(screen.getByRole("option", { name: "Massachusetts (MA)" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
|
||||
fireEvent.change(input, { target: { value: "Republic of Texas" } });
|
||||
expect(screen.queryByRole("option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects by search and shows the Area helper immediately", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
|
||||
const input = openOptions();
|
||||
|
||||
fireEvent.change(input, { target: { value: "texas" } });
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Texas (TX)" }));
|
||||
|
||||
expect(input).toHaveValue("Texas (TX)");
|
||||
expect(screen.getByText("Area: West")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: "TX" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("is keyboard accessible (arrow + enter selection)", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
|
||||
const input = screen.getByRole("combobox");
|
||||
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: "ohio" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(input).toHaveValue("Ohio (OH)");
|
||||
expect(screen.getByText("Area: Central")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: "OH" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a state and rejects free text on submit", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderWithProviders(<StateSelectHarness onSubmit={onSubmit} />, { withAuth: false });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("State is required")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: "NotAState" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await screen.findByText("State is required");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
142
src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
vendored
Normal file
142
src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
68
src/test/components/common/address-map-preview.test.tsx
Normal file
68
src/test/components/common/address-map-preview.test.tsx
Normal 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
165
src/test/data/areas.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AREA_ZONE_STATE_CODES,
|
||||
AREA_ZONES,
|
||||
STATE_AREA_ENTRIES,
|
||||
US_STATE_CODES,
|
||||
expandAreasToStateCodes,
|
||||
formatStateOptionLabel,
|
||||
getAreaForState,
|
||||
normalizeStateCode,
|
||||
} from "@/data/areas";
|
||||
|
||||
const EAST = [
|
||||
"CT",
|
||||
"DE",
|
||||
"GA",
|
||||
"MA",
|
||||
"MD",
|
||||
"ME",
|
||||
"NC",
|
||||
"NH",
|
||||
"NJ",
|
||||
"NY",
|
||||
"PA",
|
||||
"RI",
|
||||
"SC",
|
||||
"VA",
|
||||
"VT",
|
||||
"WV",
|
||||
];
|
||||
const CENTRAL = [
|
||||
"AL",
|
||||
"AR",
|
||||
"FL",
|
||||
"IA",
|
||||
"IL",
|
||||
"IN",
|
||||
"KS",
|
||||
"KY",
|
||||
"LA",
|
||||
"MI",
|
||||
"MN",
|
||||
"MO",
|
||||
"MS",
|
||||
"ND",
|
||||
"NE",
|
||||
"OH",
|
||||
"OK",
|
||||
"SD",
|
||||
"TN",
|
||||
"WI",
|
||||
];
|
||||
const WEST = ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"];
|
||||
|
||||
describe("state area catalog", () => {
|
||||
it("covers exactly the 50 states with unique codes and names", () => {
|
||||
expect(STATE_AREA_ENTRIES).toHaveLength(50);
|
||||
expect(US_STATE_CODES).toHaveLength(50);
|
||||
expect(new Set(US_STATE_CODES).size).toBe(50);
|
||||
expect(new Set(STATE_AREA_ENTRIES.map((entry) => entry.name)).size).toBe(50);
|
||||
});
|
||||
|
||||
it("excludes DC and territories", () => {
|
||||
expect(US_STATE_CODES).not.toContain("DC");
|
||||
expect(US_STATE_CODES).not.toContain("PR");
|
||||
expect(US_STATE_CODES).not.toContain("GU");
|
||||
expect(US_STATE_CODES).not.toContain("VI");
|
||||
expect(US_STATE_CODES).not.toContain("AS");
|
||||
expect(US_STATE_CODES).not.toContain("MP");
|
||||
});
|
||||
|
||||
it("maps every state to exactly one confirmed zone with counts", () => {
|
||||
expect([...AREA_ZONE_STATE_CODES.East]).toEqual(EAST);
|
||||
expect([...AREA_ZONE_STATE_CODES.Central]).toEqual(CENTRAL);
|
||||
expect([...AREA_ZONE_STATE_CODES.West]).toEqual(WEST);
|
||||
expect([...AREA_ZONE_STATE_CODES.California]).toEqual(["CA"]);
|
||||
|
||||
expect(EAST).toHaveLength(16);
|
||||
expect(CENTRAL).toHaveLength(20);
|
||||
expect(WEST).toHaveLength(13);
|
||||
|
||||
for (const entry of STATE_AREA_ENTRIES) {
|
||||
expect(AREA_ZONES).toContain(entry.zone);
|
||||
expect(entry.code).toMatch(/^[A-Z]{2}$/);
|
||||
expect(entry.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the canonical zone order", () => {
|
||||
expect([...AREA_ZONES]).toEqual(["East", "Central", "West", "California"]);
|
||||
expect(US_STATE_CODES[0]).toBe("CT");
|
||||
expect(US_STATE_CODES[49]).toBe("CA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStateOptionLabel", () => {
|
||||
it("renders Name (CODE)", () => {
|
||||
expect(formatStateOptionLabel("TX")).toBe("Texas (TX)");
|
||||
expect(formatStateOptionLabel("CA")).toBe("California (CA)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStateCode", () => {
|
||||
it("accepts canonical codes in any case", () => {
|
||||
expect(normalizeStateCode("TX")).toBe("TX");
|
||||
expect(normalizeStateCode("tx")).toBe("TX");
|
||||
expect(normalizeStateCode("Mo")).toBe("MO");
|
||||
});
|
||||
|
||||
it("normalizes legacy full-name and lowercase values", () => {
|
||||
expect(normalizeStateCode("Texas")).toBe("TX");
|
||||
expect(normalizeStateCode("texas")).toBe("TX");
|
||||
expect(normalizeStateCode(" Illinois ")).toBe("IL");
|
||||
expect(normalizeStateCode("NORTH CAROLINA")).toBe("NC");
|
||||
expect(normalizeStateCode("California")).toBe("CA");
|
||||
});
|
||||
|
||||
it("returns null for empty, unknown, and non-state values", () => {
|
||||
expect(normalizeStateCode("")).toBeNull();
|
||||
expect(normalizeStateCode(" ")).toBeNull();
|
||||
expect(normalizeStateCode(null)).toBeNull();
|
||||
expect(normalizeStateCode(undefined)).toBeNull();
|
||||
expect(normalizeStateCode("ZZ")).toBeNull();
|
||||
expect(normalizeStateCode("DC")).toBeNull();
|
||||
expect(normalizeStateCode("Puerto Rico")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAreaForState", () => {
|
||||
it("derives the confirmed zone per state", () => {
|
||||
expect(getAreaForState("CT")).toBe("East");
|
||||
expect(getAreaForState("IL")).toBe("Central");
|
||||
expect(getAreaForState("TX")).toBe("West");
|
||||
expect(getAreaForState("CA")).toBe("California");
|
||||
});
|
||||
|
||||
it("derives the zone from legacy full-name values", () => {
|
||||
expect(getAreaForState("illinois")).toBe("Central");
|
||||
expect(getAreaForState("Texas")).toBe("West");
|
||||
});
|
||||
|
||||
it("returns null for unknown states", () => {
|
||||
expect(getAreaForState("XX")).toBeNull();
|
||||
expect(getAreaForState("")).toBeNull();
|
||||
expect(getAreaForState(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandAreasToStateCodes", () => {
|
||||
it("returns an empty list for an empty selection", () => {
|
||||
expect(expandAreasToStateCodes([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands a single zone in canonical code order", () => {
|
||||
expect(expandAreasToStateCodes(["East"])).toEqual(EAST);
|
||||
expect(expandAreasToStateCodes(["California"])).toEqual(["CA"]);
|
||||
expect(expandAreasToStateCodes(["West"])).toEqual(WEST);
|
||||
});
|
||||
|
||||
it("expands multiple zones in canonical zone order and dedupes", () => {
|
||||
expect(expandAreasToStateCodes(["West", "California"])).toEqual([...WEST, "CA"]);
|
||||
expect(expandAreasToStateCodes(["California", "West", "West"])).toEqual([...WEST, "CA"]);
|
||||
expect(expandAreasToStateCodes([...AREA_ZONES])).toEqual(US_STATE_CODES);
|
||||
});
|
||||
});
|
||||
60
src/test/domain/locations/api/locations-list-api.test.ts
Normal file
60
src/test/domain/locations/api/locations-list-api.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
import { expandAreasToStateCodes } from "@/data/areas";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
|
||||
describe("locationsApi.getList states query param", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("joins state codes into a comma-separated param", async () => {
|
||||
await locationsApi.getList({ states: ["TX", "MO"] });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, {
|
||||
searchParams: { search: "", page: 1, pageSize: 12, states: "TX,MO" },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the states param when no states are provided", async () => {
|
||||
await locationsApi.getList({});
|
||||
|
||||
const [, config] = apiGet.mock.calls[0];
|
||||
expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 });
|
||||
expect("states" in config.searchParams).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the states param when the states list is empty", async () => {
|
||||
await locationsApi.getList({ states: [] });
|
||||
|
||||
const [, config] = apiGet.mock.calls[0];
|
||||
expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 });
|
||||
expect("states" in config.searchParams).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the canonical area expansion order in the joined value", async () => {
|
||||
await locationsApi.getList({ states: expandAreasToStateCodes(["West", "California"]) });
|
||||
|
||||
const [, config] = apiGet.mock.calls[0];
|
||||
expect(config.searchParams.states).toBe("AK,AZ,CO,HI,ID,MT,NM,NV,OR,TX,UT,WA,WY,CA");
|
||||
});
|
||||
|
||||
it("keeps existing search and pagination params", async () => {
|
||||
await locationsApi.getList({ search: "dallas", page: 2, pageSize: 24, states: ["TX"] });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, {
|
||||
searchParams: { search: "dallas", page: 2, pageSize: 24, states: "TX" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ function formValues(
|
|||
city: roster.city,
|
||||
state: roster.state,
|
||||
zip: roster.zip,
|
||||
googleMapsUrl: roster.googleMapsUrl,
|
||||
notes: roster.notes,
|
||||
technicians: [newTechnician],
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
89
src/test/lib/address/vendor-address.test.ts
Normal file
89
src/test/lib/address/vendor-address.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue