mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
27 commits
1a7a4ba58f
...
744d7af7bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744d7af7bb | ||
|
|
d7ee290b1d | ||
|
|
4d3dd82afb | ||
|
|
e1b03dc151 | ||
|
|
148f5f1823 | ||
| 0c867e2cb4 | |||
|
|
2600234b89 | ||
|
|
1199252673 | ||
|
|
daa590622b | ||
| ae852f15ea | |||
|
|
fa9e60ce0b | ||
|
|
6f4b3d0e43 | ||
| 7b37529006 | |||
| c99273d3fc | |||
|
|
95f0342271 | ||
| 0bf834bb8e | |||
|
|
c9562650ca | ||
|
|
8a0a134238 | ||
|
|
abe28a3a7f | ||
|
|
534d7ce4d6 | ||
|
|
65d55d7c9f | ||
|
|
0eeae02785 | ||
|
|
3dbf3df5d5 | ||
|
|
c40f0c4a37 | ||
|
|
0b229fd054 | ||
|
|
712d8e0dc6 | ||
|
|
ee95847354 |
31 changed files with 1285 additions and 273 deletions
33
.github/workflows/deploy-web.yaml
vendored
33
.github/workflows/deploy-web.yaml
vendored
|
|
@ -15,6 +15,10 @@ name: Deploy Web
|
|||
#
|
||||
# Bucket and distribution come from SSM after assuming the Environment's
|
||||
# DEPLOY_ROLE_ARN. Nothing here creates an HCP run. Quality gates live in CI.
|
||||
#
|
||||
# The SPA checkout is the resolved content ref. Deploy scripts are copied
|
||||
# from github.workflow_sha so workflow_dispatch of an older SHA still runs
|
||||
# the current upload/verify path.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -131,6 +135,27 @@ jobs:
|
|||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Checkout workflow deploy scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
path: .workflow-scripts
|
||||
|
||||
- name: Install workflow deploy scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f .workflow-scripts/scripts/upload-sourcemaps.sh
|
||||
test -f .workflow-scripts/scripts/verify-cloudfront-release.sh
|
||||
test -f .workflow-scripts/scripts/summarize-cloudfront-live-state.sh
|
||||
mkdir -p scripts
|
||||
cp .workflow-scripts/scripts/upload-sourcemaps.sh scripts/
|
||||
cp .workflow-scripts/scripts/verify-cloudfront-release.sh scripts/
|
||||
cp .workflow-scripts/scripts/summarize-cloudfront-live-state.sh scripts/
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
@ -145,6 +170,14 @@ jobs:
|
|||
VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# vars.VITE_SENTRY_DSN is unset today. An empty env value would
|
||||
# override .env.production and disable Sentry (Vite does not let
|
||||
# .env overwrite an existing variable).
|
||||
if [ -n "${VITE_SENTRY_DSN:-}" ]; then
|
||||
export VITE_SENTRY_DSN
|
||||
else
|
||||
unset VITE_SENTRY_DSN
|
||||
fi
|
||||
case "${TARGET_ENVIRONMENT}" in
|
||||
dev)
|
||||
export VITE_API_URL="https://api.dev.seahaven.com/api"
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ isolation). A task is not done until this is green.
|
|||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`). G13 fails a diff that contains both `terraform/`
|
||||
and deployable application files (`src/`, `public/`, `pages/`, `config/`,
|
||||
`index.html`, Vite/tsconfig, `.env*`, or `scripts/deploy-web.sh`). Workflow,
|
||||
`index.html`, Vite/tsconfig, or `.env*`). Workflow,
|
||||
docs, and gate-script changes may travel with either side. Runtime isolation
|
||||
stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP
|
||||
when workspace trigger patterns miss.
|
||||
|
|
|
|||
|
|
@ -87,6 +87,6 @@ surface; keep comments inline and high-signal.
|
|||
Infra and application **PRs** stay separate. GitHub Actions owns SPA content
|
||||
(`deploy-web.yaml`). HCP Terraform owns the bucket and CloudFront. A change set
|
||||
that includes both `terraform/` and deployable application files (`src/`,
|
||||
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, `.env*`, or
|
||||
`scripts/deploy-web.sh`) fails G13. Workflow, docs, and gate-script changes may
|
||||
travel with either side.
|
||||
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
|
||||
fails G13. Workflow, docs, and gate-script changes may travel with either
|
||||
side.
|
||||
|
|
|
|||
265
e2e/work-orders/inline-technician.spec.ts
Normal file
265
e2e/work-orders/inline-technician.spec.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Technicians registered inline from work-order entry points
|
||||
// become real vendor records only on save, through the additive vendor-company roster
|
||||
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-inline-technician-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 41,
|
||||
companyName: "Vinewood LLC",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
const ROSTER = {
|
||||
companyId: 7,
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
name: "Vinewood LLC",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
notes: "",
|
||||
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
|
||||
};
|
||||
|
||||
const BOARD_ROW = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
workOrderType: 2,
|
||||
siteCode: "STL-01",
|
||||
locationName: "St. Louis HQ",
|
||||
locationId: 10,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
vendorId: 41,
|
||||
vendorName: "Vinewood LLC",
|
||||
techName: "Adam Whyte",
|
||||
title: "Quarterly HVAC PM",
|
||||
rowVersion: "rv-1",
|
||||
};
|
||||
|
||||
type Captured = { method: string; pathname: string; body: unknown };
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function bodyOf(request: Request): unknown {
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
|
||||
const added = (body.addTechnicians ?? []).map((technician, index) => ({
|
||||
...technician,
|
||||
id: 42 + index,
|
||||
}));
|
||||
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
|
||||
}
|
||||
|
||||
async function mockApi(page: Page, writes: Captured[]) {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) return route.fallback();
|
||||
const method = request.method();
|
||||
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
|
||||
|
||||
if (pathname.includes("/workorders/board/search")) {
|
||||
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "GET") {
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [BOARD_ROW],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "POST") {
|
||||
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
|
||||
}
|
||||
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
|
||||
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster" && method === "GET") {
|
||||
return fulfillJson(route, ROSTER);
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
|
||||
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
|
||||
}
|
||||
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
|
||||
return fulfillJson(route, { data: VENDORS });
|
||||
}
|
||||
if (pathname.includes("/locations/sites")) {
|
||||
return fulfillJson(route, {
|
||||
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/locations/10") {
|
||||
return fulfillJson(route, {
|
||||
data: {
|
||||
id: 10,
|
||||
name: "St. Louis HQ",
|
||||
siteCode: "STL-01",
|
||||
contact: "Pat Site",
|
||||
phone: "3145550111",
|
||||
},
|
||||
});
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
function vendorWrites(writes: Captured[]) {
|
||||
return writes.filter(
|
||||
(write) =>
|
||||
write.pathname.startsWith("/api/vendors") ||
|
||||
write.pathname.startsWith("/api/vendor-company-roster"),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWizardVendorStep(page: Page) {
|
||||
await page.goto("/workorders");
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select company/i }).click();
|
||||
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
|
||||
await page.getByRole("button", { name: "+ Add technician" }).click();
|
||||
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
|
||||
await page.getByLabel("New Technician 1 phone").fill("3145550199");
|
||||
}
|
||||
|
||||
test.describe("Inline technician registration", () => {
|
||||
test("wizard persists the technician under the company on create and assigns it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
// Staging a technician is local only.
|
||||
expect(vendorWrites(writes)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "Create work order" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
|
||||
.toBeTruthy();
|
||||
expect(vendorWrites(writes)).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
pathname: "/api/vendor-company-roster/7",
|
||||
body: {
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
addTechnicians: [
|
||||
{
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-0199",
|
||||
email: "",
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const create = writes.find((write) => write.pathname === "/api/workorders/board");
|
||||
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
|
||||
});
|
||||
|
||||
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
|
||||
|
||||
// Reopening starts clean: the staged technician was discarded, not persisted.
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await page.goto("/workorders");
|
||||
|
||||
await page
|
||||
.locator("#wo-row-1")
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
|
||||
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
|
||||
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
|
||||
await dialog.getByRole("button", { name: /^add$/i }).click();
|
||||
await dialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
|
||||
.toBeTruthy();
|
||||
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
|
||||
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
|
||||
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
|
||||
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
|
||||
expect(JSON.stringify(boardPatch?.body)).toContain("42");
|
||||
});
|
||||
});
|
||||
|
|
@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
|
|||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /Select company/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Gateway Plumbing/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.getByRole("button", { name: /Select technician/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import sys
|
||||
|
||||
APP_SCRIPT_NAMES = {
|
||||
"scripts/deploy-web.sh",
|
||||
}
|
||||
|
||||
APP_ROOTS = (
|
||||
"src/",
|
||||
"public/",
|
||||
|
|
@ -33,7 +29,7 @@ def is_terraform_path(path: str) -> bool:
|
|||
|
||||
def is_app_path(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/")
|
||||
if normalized in APP_SCRIPT_NAMES or normalized in APP_FILES:
|
||||
if normalized in APP_FILES:
|
||||
return True
|
||||
if normalized in {"src", "public", "pages", "config"}:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--id "${INVALIDATION_ID}"
|
||||
fi
|
||||
|
||||
echo "Web deploy complete."
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
data = json.loads(raw) if raw else {}
|
||||
current = data.get("current") or ""
|
||||
previous = data.get("previous") or ""
|
||||
output_path = os.environ["GITHUB_OUTPUT"]
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"live_current={current}\n")
|
||||
handle.write(f"live_previous={previous}\n")
|
||||
print(
|
||||
"Pointer live current="
|
||||
+ (current or "<empty>")
|
||||
+ " previous="
|
||||
+ (previous or "<empty>")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -340,13 +340,13 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists())
|
||||
self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists())
|
||||
|
||||
def test_dev_adoption_complete_is_pinned_in_locals(self) -> None:
|
||||
def test_adoption_complete_is_pinned_in_locals(self) -> None:
|
||||
dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertRegex(dev, r"adoption_complete\s+= true")
|
||||
self.assertRegex(staging, r"adoption_complete\s+= false")
|
||||
self.assertRegex(staging, r"adoption_complete\s+= true")
|
||||
self.assertNotIn('variable "adoption_complete"', dev)
|
||||
self.assertNotIn('variable "environment"', dev)
|
||||
self.assertNotIn('variable "release_version_label"', dev)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ class IsolationTests(unittest.TestCase):
|
|||
"src/app/routes.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"scripts/deploy-web.sh",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ fi
|
|||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
|
||||
if ! npm exec --no -- sentry-cli --version >/dev/null 2>&1; then
|
||||
echo "sentry-cli is not in this SPA tree; skipping source-map upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
|
|
@ -37,7 +37,7 @@ export function useVendorDialogForm({
|
|||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const addTechnicians = useAddTechniciansToCompany();
|
||||
const [selectedCompany, setSelectedCompany] = useState(company);
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
|
|
@ -119,7 +119,7 @@ export function useVendorDialogForm({
|
|||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -141,7 +141,7 @@ export function useVendorDialogForm({
|
|||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
isPending: addTechnicians.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
|
||||
type UseVendorDialogSaveArgs = {
|
||||
allVendors: VendorDropdownItem[];
|
||||
|
|
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
|
|||
anchorCompanyName: string;
|
||||
tech: string;
|
||||
phone: string;
|
||||
createVendor: ReturnType<typeof useCreateVendor>;
|
||||
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
|
||||
async function persistDraftTechnician({
|
||||
allVendors,
|
||||
pendingPayload,
|
||||
phone,
|
||||
addTechnicians,
|
||||
}: {
|
||||
allVendors: VendorDropdownItem[];
|
||||
pendingPayload: VendorFormValues;
|
||||
phone: string;
|
||||
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
|
||||
}): Promise<VendorDropdownItem> {
|
||||
const anchor = allVendors.find(
|
||||
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
|
||||
);
|
||||
if (!anchor) {
|
||||
throw new Error("Select an existing vendor company before adding a technician.");
|
||||
}
|
||||
const [created] = await addTechnicians.mutateAsync({
|
||||
anchorVendorId: anchor.id,
|
||||
technicians: [
|
||||
{
|
||||
contactName: pendingPayload.contactName,
|
||||
phone: phone.trim() || (pendingPayload.phone ?? ""),
|
||||
tradeSpecialties: pendingPayload.tradeSpecialties,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (created == null) throw new Error("Failed to create technician");
|
||||
return created;
|
||||
}
|
||||
|
||||
export function useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
|
|
@ -30,7 +61,7 @@ export function useVendorDialogSave({
|
|||
anchorCompanyName,
|
||||
tech,
|
||||
phone,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -53,11 +84,12 @@ export function useVendorDialogSave({
|
|||
|
||||
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
|
||||
try {
|
||||
const created = await createVendor.mutateAsync({
|
||||
...pendingPayload,
|
||||
phone: phone.trim() || pendingPayload.phone,
|
||||
saveVendor = await persistDraftTechnician({
|
||||
allVendors,
|
||||
pendingPayload,
|
||||
phone,
|
||||
addTechnicians,
|
||||
});
|
||||
saveVendor = vendorToDropdownItem(created);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create technician";
|
||||
setSaveError(message);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
|
|||
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
|
||||
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
|
||||
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
|
||||
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
|
||||
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ export function useNewWoWizardController({
|
|||
}
|
||||
};
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
|
||||
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
|
||||
// Close the dialog first: the work-order create's isPending has already dropped, so
|
||||
// holding the dialog open through the notes save would re-enable Create and unlock
|
||||
// pointer events, and a second click would create a second work order. The save runs
|
||||
|
|
@ -72,6 +73,11 @@ export function useNewWoWizardController({
|
|||
onOpenChange(false);
|
||||
void saveCompanyNotes(createdDraft);
|
||||
});
|
||||
const createMutation = useWizardCreateWithTechnicians({
|
||||
vendors,
|
||||
patchDraft: draftState.patchDraft,
|
||||
createMutation: createWorkOrder,
|
||||
});
|
||||
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
|
||||
useWizardDuplicateActions({
|
||||
open,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { toast } from "react-toastify";
|
||||
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type {
|
||||
WizardNewTechnician,
|
||||
WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type UseWizardCreateWithTechniciansArgs = {
|
||||
vendors: VendorDropdownItem[];
|
||||
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
createMutation: {
|
||||
mutate: (draft: WorkOrderWizardDraft) => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
|
||||
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
|
||||
}
|
||||
|
||||
/** Draft changes applied once inline technicians exist as real vendor records. */
|
||||
export function buildCreatedTechniciansPatch(
|
||||
draft: WorkOrderWizardDraft,
|
||||
company: string,
|
||||
pending: WizardNewTechnician[],
|
||||
created: VendorDropdownItem[],
|
||||
): Partial<WorkOrderWizardDraft> {
|
||||
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
|
||||
const [only] = created;
|
||||
// Prototype parity: a single new technician is assigned when none was picked.
|
||||
if (created.length === 1 && only != null && draft.vendorId === "") {
|
||||
patch.vendorId = String(only.id);
|
||||
patch.vendorName = company;
|
||||
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists technicians registered inline on the Vendor & time step under the
|
||||
* selected company, then creates the work order. Nothing is written before Create, so
|
||||
* cancelling the wizard leaves no stray vendor records.
|
||||
*/
|
||||
export function useWizardCreateWithTechnicians({
|
||||
vendors,
|
||||
patchDraft,
|
||||
createMutation,
|
||||
}: UseWizardCreateWithTechniciansArgs) {
|
||||
const addTechnicians = useAddTechniciansToCompany();
|
||||
|
||||
const mutate = (draft: WorkOrderWizardDraft) => {
|
||||
const pending = pendingTechnicians(draft);
|
||||
const company = (draft.vendorCompany ?? "").trim();
|
||||
if (pending.length === 0 || company === "") {
|
||||
createMutation.mutate(draft);
|
||||
return;
|
||||
}
|
||||
// Mirror the vendor roster schema so an unparseable phone is caught here with a
|
||||
// clear message instead of failing the roster PATCH and blocking Create with the
|
||||
// raw backend sentence. A blank phone stays valid, matching the modal path.
|
||||
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
|
||||
toast.error("Enter a 10-digit phone number for each new technician.");
|
||||
return;
|
||||
}
|
||||
const anchor = vendors.find((vendor) => vendor.companyName === company);
|
||||
if (!anchor) {
|
||||
toast.error("Select an existing vendor company before adding technicians.");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const created = await addTechnicians.mutateAsync({
|
||||
anchorVendorId: anchor.id,
|
||||
technicians: pending.map((technician) => ({
|
||||
contactName: technician.name,
|
||||
phone: technician.phone,
|
||||
tradeSpecialties: anchor.tradeSpecialties,
|
||||
})),
|
||||
});
|
||||
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
|
||||
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
|
||||
patchDraft(patch);
|
||||
createMutation.mutate({ ...draft, ...patch });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
|
||||
}
|
||||
|
|
@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
|
|||
onPatch,
|
||||
}: WizardStepLocationServiceSelectProps) {
|
||||
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
||||
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
|
||||
nextPm !== draft.pm
|
||||
? {
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}
|
||||
: {};
|
||||
|
||||
const selectSvc = (p: string) => {
|
||||
const selected = services.find((service) => service.value === p);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
|
|
@ -8,10 +9,7 @@ import {
|
|||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import {
|
||||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
|
|
@ -30,7 +28,6 @@ import {
|
|||
ColorDot,
|
||||
Detail,
|
||||
WizLabel,
|
||||
WizLabelRow,
|
||||
WizSubLabel,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
|
|
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
|
|||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.vendorId !== ""
|
||||
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Technician
|
||||
</WizLabelRow>
|
||||
<WizardFieldSelect
|
||||
value={draft.vendorId}
|
||||
placeholder="Select technician…"
|
||||
icon={
|
||||
selectedVendor ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{(selectedVendor.contactName || selectedVendor.companyName)
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
)
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={vendors.map((v) => ({
|
||||
key: String(v.id),
|
||||
label: formatVendorTechnicianLabel(v),
|
||||
}))}
|
||||
onPick={(id) => {
|
||||
const vendor = vendors.find((v) => String(v.id) === id);
|
||||
onPatch({
|
||||
vendorId: id,
|
||||
vendorName: vendor?.companyName ?? "",
|
||||
techPhone: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WizardVendorCompanyFields
|
||||
draft={draft}
|
||||
vendors={vendors}
|
||||
selectedVendor={selectedVendor}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
{draft.vendorId !== "" && selectedVendor != null && (
|
||||
<WizardVendorDetail
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { Building2, Wrench } from "lucide-react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
|
||||
|
||||
type WizardVendorCompanyFieldsProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
vendors: VendorDropdownItem[];
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
};
|
||||
|
||||
const ICON_STYLE = { color: "var(--muted-foreground)" };
|
||||
|
||||
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Company-first vendor selection with inline technician registration (prototype parity). */
|
||||
export function WizardVendorCompanyFields({
|
||||
draft,
|
||||
vendors,
|
||||
selectedVendor,
|
||||
onPatch,
|
||||
}: WizardVendorCompanyFieldsProps) {
|
||||
const company = draft.vendorCompany || selectedVendor?.companyName || "";
|
||||
const companyOptions = buildCompanyOptions(vendors, company);
|
||||
const technicianOptions = vendors
|
||||
.filter((vendor) => vendor.companyName === company)
|
||||
.map((vendor) => ({
|
||||
key: String(vendor.id),
|
||||
label: vendor.contactName.trim() || vendor.companyName,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
company === ""
|
||||
? undefined
|
||||
: () =>
|
||||
onPatch({
|
||||
vendorCompany: "",
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Company
|
||||
</WizLabelRow>
|
||||
<WizardFieldSelect
|
||||
value={company}
|
||||
placeholder="Select company…"
|
||||
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
|
||||
searchPlaceholder="Search company…"
|
||||
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
|
||||
onPick={(nextCompany) =>
|
||||
onPatch({
|
||||
vendorCompany: nextCompany,
|
||||
...(nextCompany !== company
|
||||
? {
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.vendorId !== ""
|
||||
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Technician
|
||||
</WizLabelRow>
|
||||
<WizardFieldSelect
|
||||
value={draft.vendorId}
|
||||
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
|
||||
disabled={company === ""}
|
||||
icon={
|
||||
selectedVendor ? (
|
||||
<TechnicianAvatar vendor={selectedVendor} />
|
||||
) : (
|
||||
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
|
||||
)
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions}
|
||||
onPick={(id) => {
|
||||
const vendor = vendors.find((v) => String(v.id) === id);
|
||||
onPatch({
|
||||
vendorCompany: vendor?.companyName ?? company,
|
||||
vendorId: id,
|
||||
vendorName: vendor?.companyName ?? "",
|
||||
techPhone: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{company !== "" && (
|
||||
<WizardVendorNewTechnicians
|
||||
technicians={draft.newTechnicians ?? []}
|
||||
onChange={(newTechnicians) => onPatch({ newTechnicians })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { X } from "lucide-react";
|
||||
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
type WizardVendorNewTechniciansProps = {
|
||||
technicians: WizardNewTechnician[];
|
||||
onChange: (technicians: WizardNewTechnician[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline technician registration on the wizard's Vendor & time step.
|
||||
* Rows stay local to the draft; they are persisted under the selected company only
|
||||
* when the work order is created, so cancelling the wizard never creates a vendor.
|
||||
*/
|
||||
export function WizardVendorNewTechnicians({
|
||||
technicians,
|
||||
onChange,
|
||||
}: WizardVendorNewTechniciansProps) {
|
||||
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
|
||||
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
|
||||
return (
|
||||
<>
|
||||
{technicians.map((technician, index) => {
|
||||
const label = `New Technician ${index + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
|
||||
<span
|
||||
className="text-[11px] font-semibold tracking-wide uppercase"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Remove technician"
|
||||
aria-label={`Remove ${label}`}
|
||||
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<WizSubLabel>Technician name</WizSubLabel>
|
||||
<input
|
||||
aria-label={`${label} name`}
|
||||
value={technician.name}
|
||||
onChange={(e) => update(index, { name: e.target.value })}
|
||||
placeholder="Technician name"
|
||||
className={`${DLG_INPUT_CLS} mt-2`}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<WizSubLabel>Technician phone</WizSubLabel>
|
||||
<input
|
||||
type="tel"
|
||||
aria-label={`${label} phone`}
|
||||
value={technician.phone}
|
||||
onChange={(e) => update(index, { phone: e.target.value })}
|
||||
placeholder="(000) 000-0000"
|
||||
className={`${DLG_INPUT_CLS} mt-2`}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
|
||||
className="text-left text-[12px] font-medium"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
+ Add technician
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal file
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
|
||||
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export interface NewCompanyTechnician {
|
||||
contactName: string;
|
||||
phone: string;
|
||||
tradeSpecialties?: string;
|
||||
}
|
||||
|
||||
export interface AddTechniciansToCompanyInput {
|
||||
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
|
||||
anchorVendorId: string | number;
|
||||
technicians: NewCompanyTechnician[];
|
||||
}
|
||||
|
||||
function takeAddedTechnician(
|
||||
added: VendorRosterTechnician[],
|
||||
contactName: string,
|
||||
): VendorRosterTechnician {
|
||||
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
|
||||
if (index < 0) {
|
||||
throw new Error("The technician was saved but could not be matched. Reload and try again.");
|
||||
}
|
||||
const [match] = added.splice(index, 1);
|
||||
return match as VendorRosterTechnician;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds technicians to an existing vendor company through the additive roster PATCH.
|
||||
*
|
||||
* Work-order entry points used to POST a whole new vendor with blank company
|
||||
* fields, which the create validator rejects (company phone/email) and which would
|
||||
* overwrite company data. The additive path only appends technicians, keeps the
|
||||
* company record untouched, and returns the persisted technician ids.
|
||||
*/
|
||||
export async function addTechniciansToCompany({
|
||||
anchorVendorId,
|
||||
technicians,
|
||||
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
|
||||
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
|
||||
if (roster.companyId == null || roster.rowVersion === "") {
|
||||
throw new Error("Vendor company could not be loaded. Try again.");
|
||||
}
|
||||
|
||||
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
|
||||
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
|
||||
rowVersion: roster.rowVersion,
|
||||
addTechnicians: technicians.map((technician) => {
|
||||
const phone = technician.phone.trim();
|
||||
return {
|
||||
contactName: technician.contactName.trim(),
|
||||
phone,
|
||||
email: "",
|
||||
tradeSpecialties: technician.tradeSpecialties ?? "",
|
||||
isActive: true,
|
||||
...(phone === "" ? {} : { preferredContact: "Phone" }),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const added = saved.technicians.filter(
|
||||
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
|
||||
);
|
||||
|
||||
return technicians.map((technician) => {
|
||||
const match = takeAddedTechnician(added, technician.contactName.trim());
|
||||
const email = match.email.trim();
|
||||
return {
|
||||
id: match.id ?? "",
|
||||
companyName: saved.name || roster.name,
|
||||
contactName: match.contactName,
|
||||
tradeSpecialties: match.tradeSpecialties,
|
||||
address: saved.address,
|
||||
distanceMiles: null,
|
||||
email: email || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTechniciansToCompany(): UseMutationResult<
|
||||
VendorDropdownItem[],
|
||||
Error,
|
||||
AddTechniciansToCompanyInput
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addTechniciansToCompany,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
toast.success("Vendor created");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create vendor");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
|
|||
|
||||
export type WizardSchedMode = "date" | "week" | "range";
|
||||
|
||||
export interface WizardNewTechnician {
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderWizardDraft {
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
|
|
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
|
|||
avetaRequired: boolean;
|
||||
vendorId: string;
|
||||
vendorName: string;
|
||||
/** Company picked on the Vendor & time step; scopes the technician list. */
|
||||
vendorCompany?: string;
|
||||
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
|
||||
newTechnicians?: WizardNewTechnician[];
|
||||
techPhone: string;
|
||||
vendorNotes: string;
|
||||
apptStart: string;
|
||||
|
|
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
avetaRequired: false,
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
vendorCompany: "",
|
||||
newTechnicians: [],
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
apptStart: "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_component
|
|||
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: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { VendorDialog } from "@/app/(protected)/workorders/_components/list/tabl
|
|||
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: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const mutateAsync = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
||||
useCreateVendor: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
@ -24,30 +23,19 @@ const existingVendor: VendorDropdownItem = {
|
|||
email: "dispatch@gateway.test",
|
||||
};
|
||||
|
||||
const createdVendor: Vendor = {
|
||||
const createdVendor: VendorDropdownItem = {
|
||||
id: 99,
|
||||
companyId: 10,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Jordan Lee",
|
||||
email: "dispatch@gateway.test",
|
||||
phone: "(314) 555-9999",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "1 Main St",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
address: "1 Main St",
|
||||
distanceMiles: null,
|
||||
};
|
||||
|
||||
describe("VendorDialog stable technician identity", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
mutateAsync.mockResolvedValue(createdVendor);
|
||||
mutateAsync.mockResolvedValue([createdVendor]);
|
||||
});
|
||||
|
||||
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
|
||||
|
|
@ -90,13 +78,15 @@ describe("VendorDialog stable technician identity", () => {
|
|||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Jordan Lee",
|
||||
companyName: "Gateway Plumbing",
|
||||
phone: "(314) 555-9999",
|
||||
}),
|
||||
);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
anchorVendorId: 10,
|
||||
technicians: [
|
||||
expect.objectContaining({
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-9999",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
|
|
@ -115,12 +105,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
|
||||
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
|
||||
const onSave = vi.fn();
|
||||
mutateAsync.mockResolvedValue({
|
||||
...createdVendor,
|
||||
id: 88,
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
});
|
||||
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
|
||||
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
|
|
@ -161,8 +146,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -178,12 +162,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
|
||||
it("keeps a staged draft when the same company is picked again", async () => {
|
||||
const onSave = vi.fn();
|
||||
mutateAsync.mockResolvedValue({
|
||||
...createdVendor,
|
||||
id: 88,
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
});
|
||||
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
|
||||
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
|
|
@ -215,8 +194,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
await waitFor(() => {
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
|||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -87,7 +89,78 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
|||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the vendor company and staged inline technicians when the service changes", () => {
|
||||
const onPatch = vi.fn();
|
||||
const draft: WorkOrderWizardDraft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
pm: "HVAC",
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<WizardStepLocationServiceSelect
|
||||
draft={draft}
|
||||
services={[
|
||||
{ id: "1", value: "HVAC" },
|
||||
{ id: "2", value: "Plumbing" },
|
||||
]}
|
||||
svcOpen={false}
|
||||
onSvcOpenChange={vi.fn()}
|
||||
svcSearch=""
|
||||
onSvcSearchChange={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith({
|
||||
pm: "Plumbing",
|
||||
serviceId: "2",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps vendor company and staged technicians when the service is unchanged", () => {
|
||||
const onPatch = vi.fn();
|
||||
const draft: WorkOrderWizardDraft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
pm: "HVAC",
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<WizardStepLocationServiceSelect
|
||||
draft={draft}
|
||||
services={[{ id: "1", value: "HVAC" }]}
|
||||
svcOpen={false}
|
||||
onSvcOpenChange={vi.fn()}
|
||||
svcSearch=""
|
||||
onSvcSearchChange={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", serviceId: "1", extraServices: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
const mutateAsync = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
const vendors: VendorDropdownItem[] = [
|
||||
{
|
||||
id: 41,
|
||||
companyName: "Vinewood LLC",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "1 Market St",
|
||||
distanceMiles: null,
|
||||
},
|
||||
{
|
||||
id: 52,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Casey Pipe",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "",
|
||||
distanceMiles: null,
|
||||
},
|
||||
];
|
||||
|
||||
function draftWith(patch: Partial<WorkOrderWizardDraft>): WorkOrderWizardDraft {
|
||||
return { ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete", ...patch };
|
||||
}
|
||||
|
||||
describe("WizardStepVendorTime inline technician (SH-364)", () => {
|
||||
it("hides the add action until a company is picked", () => {
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={draftWith({})}
|
||||
vendors={vendors}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "+ Add technician" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stages a new technician row for the picked company", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={draftWith({ vendorCompany: "Vinewood LLC" })}
|
||||
vendors={vendors}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add technician" }));
|
||||
expect(onPatch).toHaveBeenCalledWith({ newTechnicians: [{ name: "", phone: "" }] });
|
||||
});
|
||||
|
||||
it("scopes technicians to the picked company", () => {
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
|
||||
})}
|
||||
vendors={vendors}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
|
||||
expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the picked vendor and staged technicians when the company changes", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
vendorId: "41",
|
||||
vendorName: "Vinewood LLC",
|
||||
techPhone: "(314) 555-0100",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
|
||||
})}
|
||||
vendors={vendors}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith({
|
||||
vendorCompany: "Gateway Plumbing",
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the picked vendor and staged technicians when the same company is re-picked", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
vendorId: "41",
|
||||
vendorName: "Vinewood LLC",
|
||||
techPhone: "(314) 555-0100",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
|
||||
})}
|
||||
vendors={vendors}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
|
||||
const companyOptions = screen.getAllByRole("button", { name: "Vinewood LLC" });
|
||||
fireEvent.click(companyOptions[companyOptions.length - 1]);
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Vinewood LLC" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWizardCreateWithTechnicians (SH-364)", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
});
|
||||
|
||||
it("creates the work order directly when no technician was staged", () => {
|
||||
const createMutation = { mutate: vi.fn(), isPending: false };
|
||||
const { result } = renderHook(() =>
|
||||
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
|
||||
);
|
||||
const draft = draftWith({ vendorCompany: "Vinewood LLC", newTechnicians: [] });
|
||||
|
||||
result.current.mutate(draft);
|
||||
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
expect(createMutation.mutate).toHaveBeenCalledWith(draft);
|
||||
});
|
||||
|
||||
it("persists staged technicians first and assigns a single new technician", async () => {
|
||||
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
|
||||
const createMutation = { mutate: vi.fn(), isPending: false };
|
||||
const patchDraft = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useWizardCreateWithTechnicians({ vendors, patchDraft, createMutation }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate(
|
||||
draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [
|
||||
{ name: "Jordan Lee", phone: "(314) 555-0199" },
|
||||
{ name: " ", phone: "" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
anchorVendorId: 41,
|
||||
technicians: [
|
||||
{ contactName: "Jordan Lee", phone: "(314) 555-0199", tradeSpecialties: "HVAC" },
|
||||
],
|
||||
});
|
||||
const expectedPatch = {
|
||||
newTechnicians: [],
|
||||
vendorId: "99",
|
||||
vendorName: "Vinewood LLC",
|
||||
techPhone: "(314) 555-0199",
|
||||
};
|
||||
expect(patchDraft).toHaveBeenCalledWith(expectedPatch);
|
||||
expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch));
|
||||
});
|
||||
|
||||
it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => {
|
||||
const createMutation = { mutate: vi.fn(), isPending: false };
|
||||
const { result } = renderHook(() =>
|
||||
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate(
|
||||
draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-01" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
expect(createMutation.mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a staged technician with a blank phone, matching the modal path", async () => {
|
||||
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
|
||||
const createMutation = { mutate: vi.fn(), isPending: false };
|
||||
const { result } = renderHook(() =>
|
||||
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate(
|
||||
draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
anchorVendorId: 41,
|
||||
technicians: [{ contactName: "Jordan Lee", phone: "", tradeSpecialties: "HVAC" }],
|
||||
});
|
||||
expect(createMutation.mutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create the work order when the technician save fails", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("boom"));
|
||||
const createMutation = { mutate: vi.fn(), isPending: false };
|
||||
const { result } = renderHook(() =>
|
||||
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate(
|
||||
draftWith({
|
||||
vendorCompany: "Vinewood LLC",
|
||||
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(createMutation.mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal file
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
}));
|
||||
|
||||
import { addTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
|
||||
const roster = {
|
||||
companyId: 7,
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
name: "Vinewood LLC",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "office@vinewood.test",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
notes: "",
|
||||
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
|
||||
};
|
||||
|
||||
describe("addTechniciansToCompany (SH-365)", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPut.mockReset();
|
||||
apiPatch.mockReset();
|
||||
apiGet.mockResolvedValue(roster);
|
||||
apiPatch.mockResolvedValue({
|
||||
...roster,
|
||||
rowVersion: "AAAAAAAAB9F=",
|
||||
technicians: [
|
||||
...roster.technicians,
|
||||
{ id: 42, contactName: "Jordan Lee", phone: "(314) 555-0199", email: "", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("appends the technician through the additive roster PATCH without company fields", async () => {
|
||||
const created = await addTechniciansToCompany({
|
||||
anchorVendorId: 41,
|
||||
technicians: [{ contactName: " Jordan Lee ", phone: "3145550199", tradeSpecialties: "HVAC" }],
|
||||
});
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("vendor-company-roster", {
|
||||
searchParams: { vendorId: "41" },
|
||||
});
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
expect(apiPut).not.toHaveBeenCalled();
|
||||
expect(apiPatch).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = apiPatch.mock.calls[0] ?? [];
|
||||
expect(path).toBe("vendor-company-roster/7");
|
||||
// No companyFields: an existing company's phone/email/address are never sent or blanked.
|
||||
expect(body).toEqual({
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
addTechnicians: [
|
||||
{
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-0199",
|
||||
email: "",
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({ id: 42, contactName: "Jordan Lee", companyName: "Vinewood LLC" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails instead of returning a technician the server did not persist", async () => {
|
||||
apiPatch.mockResolvedValue(roster);
|
||||
|
||||
await expect(
|
||||
addTechniciansToCompany({
|
||||
anchorVendorId: 41,
|
||||
technicians: [{ contactName: "Jordan Lee", phone: "" }],
|
||||
}),
|
||||
).rejects.toThrow(/could not be matched/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,10 +7,10 @@ to the bucket root and invalidates `/*`.
|
|||
|
||||
Creating, formatting, initializing with `-backend=false`, and validating these
|
||||
files does not authorize an AWS, HCP Terraform, GitHub, or deployment
|
||||
mutation. Live cutover waits for an explicit greenlight.
|
||||
mutation.
|
||||
|
||||
Do not collapse these roots into one `terraform/` tree in this PR. Flattening
|
||||
retargets two live HCP working directories and is its own change.
|
||||
Do not collapse these roots into one `terraform/` tree. Flattening retargets
|
||||
two live HCP working directories and is its own change.
|
||||
|
||||
## Fixed targets
|
||||
|
||||
|
|
@ -61,15 +61,14 @@ and gate-script changes may travel with either side. G13 is
|
|||
`npm run test:terraform` and `npm run verify` wrap the same gates. They never
|
||||
create an HCP run or touch AWS.
|
||||
|
||||
## Cutover (greenlight only)
|
||||
## Workspaces
|
||||
|
||||
1. Keep HCP working directories `terraform/live/dev` and
|
||||
`terraform/live/staging`. Dev VCS branch `main`. Staging tag regex
|
||||
`^v[0-9]+\.[0-9]+\.[0-9]+-staging$`.
|
||||
2. Auto-apply off. Apply the origin-path move for **dev** before any
|
||||
`--delete` root sync.
|
||||
3. Create GitHub Environment `dev` (staging already exists). Set
|
||||
`DEPLOY_ROLE_ARN` on each.
|
||||
4. Enable `deploy-web.yaml`. Then delete leftover `deploy.yml` /
|
||||
`deploy-staging.yml` and repo `TF_API_TOKEN`, `TERRAFORM_CONTENT_CD_ENABLED`,
|
||||
and `AWS_DEPLOY_ROLE_ARN`.
|
||||
Dev (`shoc-frontend-new-dev`) watches `main` with working directory
|
||||
`terraform/live/dev` and auto-apply on. Staging (`shoc-frontend-new-staging`)
|
||||
watches tag regex `^v[0-9]+\.[0-9]+\.[0-9]+-staging$` with working directory
|
||||
`terraform/live/staging` and auto-apply on. Merges to `main` do not apply
|
||||
staging.
|
||||
|
||||
GitHub Environments `dev` and `staging` set `DEPLOY_ROLE_ARN` and allow `main`
|
||||
plus tag `v*`. Promote staging with `gh release create vX.Y.Z-staging --target
|
||||
main`.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Live Terraform roots
|
||||
|
||||
`live/dev/` is the adopted HCP workspace `shoc-frontend-new-dev`.
|
||||
`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = false`).
|
||||
`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = true`).
|
||||
|
||||
Do not collapse these into one `terraform/` root in the same PR as application
|
||||
CD. Flattening retargets two live HCP working directories and belongs in its
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
locals {
|
||||
# Staging is not fully adopted. Pinned in code, never a workspace variable.
|
||||
adoption_complete = false
|
||||
# Controlled ownership transfer. Pinned in code, never a workspace variable.
|
||||
adoption_complete = true
|
||||
|
||||
environment = "staging"
|
||||
workspace_name = "shoc-frontend-new-staging"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue