Merge branch 'dev' into feat/sh-167-wo-board-carry-over

This commit is contained in:
Arthur Bassi 2026-09-02 09:32:58 -03:00 • committed by GitHub
commit 08e00c213d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 3518 additions and 375 deletions

View file

@ -2,9 +2,9 @@ name: Frontend checks
on:
pull_request:
branches: [main, dev]
branches: [main, dev, staging]
push:
branches: [main, dev]
branches: [main, dev, staging]
workflow_dispatch: {}
permissions:
@ -14,7 +14,7 @@ jobs:
build-and-test:
name: Build and test
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
@ -32,26 +32,43 @@ jobs:
# push-> the previous commit on the branch (github.event.before)
# manual -> dev, for exact-head recovery runs
runs-on: ubuntu-latest
env:
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- name: Resolve governance comparison ref
id: governance-ref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
base="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run verify
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
visual-regression:
name: Visual regression
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
@ -59,7 +76,7 @@ jobs:
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |

140
.github/workflows/deploy-staging.yml vendored Normal file
View file

@ -0,0 +1,140 @@
name: Deploy staging
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
# job to declare `environment: staging` and run in this repo, with the
# non-secret role ARN pinned below (created by the staging stack itself).
#
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
# No secrets are used — OIDC + the static role ARN are the only credentials.
on:
push:
branches: [staging]
workflow_dispatch: {}
permissions:
id-token: write
contents: read
concurrency:
group: deploy-staging
cancel-in-progress: false
jobs:
deploy:
name: Deploy to staging
# Deploy only the exact staging branch ref, never a tag or other ref
# (workflow_dispatch can be invoked from arbitrary refs).
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
environment: staging
env:
VITE_API_URL: https://api.staging.seahaven.com/api
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Resolve governance comparison ref
id: governance-ref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
# (npm@11.16.0) matches this CI environment.
- name: Quality gates (full verify before any deploy)
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
- name: Assume staging deploy role (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
aws-region: us-east-1
# Builds the SPA with the staging VITE_API_URL (process env overrides the
# dev value committed in .env.production), syncs to the staging bucket,
# and invalidates CloudFront.
- name: Build and publish SPA
run: bash scripts/deploy-web.sh
env:
STACK_NAME: shoc-frontend-staging
WAIT_FOR_INVALIDATION: "true"
- name: Verify deployment
run: |
set -euo pipefail
stack_output() {
aws cloudformation describe-stacks \
--stack-name shoc-frontend-staging \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
DIST_DOMAIN="$(stack_output DistributionDomainName)"
SITE_URL="$(stack_output SiteUrl)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
exit 1
fi
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
aws s3api head-bucket --bucket "${BUCKET}"
echo "Bucket exists."
# The distribution is proven to exist and serve by the HTTPS check
# below: the custom domain is an alias to this distribution, and the
# deploy role deliberately carries no cloudfront:GetDistribution
# (least privilege; the dev template is shared and must not drift).
if grep -Rq "api.dev.seahaven.com" dist/; then
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
exit 1
fi
echo "Built assets carry no dev API URL."
grep -Rq "api.staging.seahaven.com" dist/
echo "Built assets reference the staging API URL."
# Verify the actual post-invalidation HTML and its referenced assets,
# not only the local build or a generic endpoint response.
remote_dir="$(mktemp -d)"
trap 'rm -rf "${remote_dir}"' EXIT
for i in 1 2 3 4 5 6; do
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
break
fi
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
sleep 20
done
test -s "${remote_dir}/index.html"
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
| sort -u > "${remote_dir}/asset-paths.txt"
test -s "${remote_dir}/asset-paths.txt"
while IFS= read -r asset_path; do
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
>> "${remote_dir}/assets.txt"
done < "${remote_dir}/asset-paths.txt"
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
echo "::error::Deployed assets contain the dev API URL." >&2
exit 1
fi
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
echo "Deployed staging assets reference only the staging API URL."

View file

@ -27,7 +27,7 @@ concurrency:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
region: us-east-1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -61,6 +61,16 @@ const inactiveVendors = [
},
];
const LONG_TEXT_VENDOR = {
CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis",
ContactName: "Adam Christopher Whyte Senior Field Technician",
Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example",
Phone: "+1 (314) 555-0198 extension 123456",
Address: "New York, NY, Estados Unidos, 123 Long Address Avenue",
City: "",
State: "",
};
interface MockState {
listUrls: string[];
createdBody?: Record<string, unknown>;
@ -88,10 +98,12 @@ async function mockVendorApi(
deactivationBlocked?: boolean;
deleteConflict?: boolean;
legacyGateway?: boolean;
longText?: boolean;
} = {},
) {
const state: MockState = { listUrls: [] };
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR);
if (options.legacyGateway) {
vendorRecords[0].Phone = "555-1234 ext 9";
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
@ -437,6 +449,47 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("truncates every non-abbreviated textual column", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await mockVendorApi(page, { longText: true });
await page.goto("/vendors");
const row = page.locator("tbody tr").first();
const fields = [
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
{ cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
{ cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address },
{ cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone },
{ cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email },
];
for (const expected of fields) {
const cell = row.locator("td").nth(expected.cellIndex);
const field = cell.getByTestId(expected.testId);
await expect(field).toBeVisible();
const metrics = await field.evaluate((element) => {
const fieldRect = element.getBoundingClientRect();
const cellRect = element.closest("td")?.getBoundingClientRect();
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
withinCell:
cellRect !== undefined &&
fieldRect.left >= cellRect.left - 1 &&
fieldRect.right <= cellRect.right + 1,
};
});
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
expect(metrics.withinCell).toBe(true);
await field.hover();
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible();
await page.mouse.move(0, 0);
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0);
}
});
test("adds a technician to an existing company, then views and edits the aggregate", async ({
page,
}) => {
@ -450,8 +503,86 @@ test.describe("Vendor directory prototype parity", () => {
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).click();
const companyLabel = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator("label")
.filter({ hasText: "Company (required)" });
await expect(companyLabel).toBeVisible();
const companyLabelMetrics = await companyLabel.evaluate((element) => {
const field = element.closest(".MuiFormControl-root");
const labelRect = element.getBoundingClientRect();
const fieldRect = field?.getBoundingClientRect();
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
withinFieldGeometry: fieldRect
? labelRect.left >= fieldRect.left &&
labelRect.right <= fieldRect.right &&
labelRect.top >= fieldRect.top &&
labelRect.bottom <= fieldRect.bottom
: false,
};
});
expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth);
expect(companyLabelMetrics.withinFieldGeometry).toBe(true);
await page.getByRole("combobox", { name: "Company (required)" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
const floatedCompanyLabel = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator("label")
.filter({ hasText: "Company (required)" });
const floatedCompanyField = floatedCompanyLabel.locator("..");
await expect(floatedCompanyLabel).toHaveText("Company (required)");
await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)");
const floatedMetrics = await floatedCompanyLabel.evaluate((element) => {
const labelRect = element.getBoundingClientRect();
const legendRect = element
.closest(".MuiFormControl-root")
?.querySelector("legend")
?.getBoundingClientRect();
return {
labelClientWidth: element.clientWidth,
labelScrollWidth: element.scrollWidth,
legendWidth: legendRect?.width ?? 0,
labelWidth: labelRect.width,
};
});
expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth);
expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth);
// Scrolling focus down the form and back to Company must keep the floated label
// inside the dialog's visible scrollport.
const dialogContent = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator(".MuiDialogContent-root");
await page.getByLabel("Technician name (optional)").click();
await expect
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await page.getByRole("combobox", { name: "Company (required)" }).click();
await expect
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
.toBeLessThanOrEqual(1);
await expect(floatedCompanyLabel).toBeVisible();
const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => {
const content = element.closest(".MuiDialogContent-root");
return {
labelTop: element.getBoundingClientRect().top,
labelBottom: element.getBoundingClientRect().bottom,
dialogTop: content?.getBoundingClientRect().top ?? Number.NaN,
dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN,
overflowY: content ? getComputedStyle(content).overflowY : "",
};
});
expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY);
expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual(
labelScrollportMetrics.dialogTop - 1,
);
expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual(
labelScrollportMetrics.dialogBottom + 1,
);
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
@ -560,7 +691,7 @@ test.describe("Vendor directory prototype parity", () => {
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();

View file

@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow.
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
- **Environments:** `dev` only today, deployed on push to the `dev` branch.
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
```
infra/cdk/
@ -25,8 +26,9 @@ infra/cdk/
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yml quality gates (lint / build / test / e2e)
ci.yaml quality gates (lint / build / test / e2e)
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
deploy-staging.yml standalone staging deploy (push to staging)
```
## What the stack creates
@ -137,25 +139,83 @@ the `SiteUrl` output.
---
## Adding staging / prod later
## Staging environment (same account, exact OIDC subject)
Separate accounts: deploy this stack there with per-env `domainNames`,
`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's
`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`.
Staging lives in the same AWS account (396287094661) but deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
reusable `cd-cdk.yaml`:
Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is
baked into `vite build`** — so each environment needs its own build with its own
API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment
in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than
relying on the committed `.env.production` (which carries the dev value). The
backend must also allow CORS from each frontend origin.
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
environment subject
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
(`StringEquals` on both `aud` and `sub`). The workflow declares
`environment: staging`, so only runs in that environment can assume the role.
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
unchanged.
- **No secret:** the role ARN is static (the role name is deterministic), so
the workflow pins
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
- **Gates first:** the workflow runs the full `npm run verify` before assuming
the staging role, then runs `scripts/deploy-web.sh` with
`STACK_NAME=shoc-frontend-staging`,
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
CloudFront invalidation to complete.
- **Application-only role:** the recurring staging workflow can describe only
its exact stack, publish only to its exact bucket, and invalidate only its
exact distribution. It cannot assume the shared CDK bootstrap roles or
modify infrastructure. Staging infrastructure changes use the Administrator
command below.
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
contain the staging API URL and no dev API URL. (Not browser QA.)
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
1. **GitHub Admin — create the `staging` environment** (Settings →
Environments → New environment → `staging`). Add protection rules as
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
the environment does not exist, GitHub creates it unprotected on first use.
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
OIDC provider and bootstrap already exist in this account):
```bash
cd infra/cdk
npx cdk deploy shoc-frontend-staging \
-c envName=staging \
-c deployBranch=staging \
-c githubEnvironment=staging \
-c domainNames=staging.seahaven.com \
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
-c hostedZoneName=staging.seahaven.com
```
The `DeployRoleArn` output must match the ARN pinned in
`deploy-staging.yml` (it will — the role name is deterministic).
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
allow the `https://staging.seahaven.com` origin.
4. Push to `staging` — `ci.yaml` runs the quality gates and
`deploy-staging.yml` deploys.
### Adding prod later
Same pattern: a prod account/stack with its own contexts and, ideally, its own
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
for prod.
## Notes
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still
deploys (matches the org's push-time-CD model). Gating deploy on CI is a
follow-up, not part of enabling CICD.
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
differs only in that its CD workflow also runs `npm run verify` itself
before deploying); a red-CI commit still deploys on `dev` (matches the
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
of enabling CICD.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -8,6 +8,10 @@ const app = new App();
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
// branch-ref trust.
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
@ -21,10 +25,17 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
const terminationProtection = envName !== "dev";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
githubEnvironment,
terminationProtection,
domainNames,
certificateArn,
hostedZoneId,

View file

@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps {
readonly githubRepo: string;
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
* trust uses the EXACT environment subject
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
*/
readonly githubEnvironment?: string;
/**
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain.
@ -57,6 +64,7 @@ export class FrontendStack extends Stack {
envName,
githubRepo,
deployBranch,
githubEnvironment = "",
domainNames,
certificateArn,
hostedZoneId,
@ -147,35 +155,52 @@ export class FrontendStack extends Stack {
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
);
// Trust conditions for the OIDC principal. With a GitHub environment
// (staging): exact StringEquals match on both aud and the environment
// subject — the staging workflow declares `environment: staging`, so only
// runs in that environment can assume the role. Without one (dev): keep
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
// deploy branch (reusable-workflow runs still carry the caller-based sub).
const oidcConditions = githubEnvironment
? {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
},
}
: {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
};
const deployRole = new iam.Role(this, "GithubDeployRole", {
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
}),
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
});
// The whole `cd-cdk.yaml` job runs as this role. Permissions it needs:
// 1. assume the CDK bootstrap roles -> `cdk deploy`
// 2. describe the stack -> cd-cdk pre-flight / health-check / output reads
// 3. read/write the bucket -> post-deploy `aws s3 sync`
// 4. invalidate the distribution -> post-deploy cache bust
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
}),
);
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
// intentionally narrower: its recurring promotion workflow only publishes
// application assets to this stack's bucket/distribution. Infrastructure
// changes remain an administrator-run CDK operation, so the staging OIDC
// role cannot inherit the bootstrap roles' account-wide deployment power.
if (!githubEnvironment) {
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
}),
);
}
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "DescribeStack",

View file

@ -13,8 +13,9 @@ 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 .env.production)..."
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
npm ci
npm run build
@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--content-type "text/html"
echo "Invalidating CloudFront ${DIST_ID}..."
aws cloudfront create-invalidation \
INVALIDATION_ID="$(aws cloudfront create-invalidation \
--distribution-id "${DIST_ID}" \
--paths "/*"
--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."

View file

@ -0,0 +1,6 @@
export function formatUpliftMoney(value: number): string {
return `$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View file

@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
export type RejectDialogProps = {
open: boolean;
amount?: number | null;
woNumber?: string | null;
onClose: () => void;
onConfirm: (reason: string) => void;
isPending?: boolean;
};
function rejectTitle(amount: number | null | undefined): string {
if (amount != null && Number.isFinite(amount)) {
return `Reject uplift of ${formatUpliftMoney(amount)}?`;
}
return "Reject uplift?";
}
export function RejectDialog({
open,
amount,
woNumber,
onClose,
onConfirm,
isPending = false,
}: RejectDialogProps) {
const [reason, setReason] = useState("");
const canConfirm = reason.trim().length > 0 && !isPending;
useEffect(() => {
if (open) {
setReason("");
}
}, [open]);
const handleClose = () => {
if (isPending) return;
setReason("");
onClose();
};
return (
<Dialog
open={open}
onClose={guardDialogClose(isPending, handleClose)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
},
}}
>
<DialogTitle
className="p-6 pb-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
{rejectTitle(amount)}
</DialogTitle>
<DialogContent className="px-6 pt-3">
{Boolean(woNumber) && (
<Text as="p" variant="description" className="mb-3">
The dispatcher sees this reason on WO #{woNumber}.
</Text>
)}
<UpliftReasonField
id="reject-uplift-reason"
label="Reason for rejection"
required
value={reason}
disabled={isPending}
placeholder="Explain why this uplift is not approved..."
onChange={setReason}
/>
</DialogContent>
<DialogActions className="px-6 pb-6 pt-2">
<DialogCancelButton onClick={handleClose} disabled={isPending} />
<Button
variant="destructive"
className="min-h-0 px-3 py-1 text-[12px]"
disabled={!canConfirm}
loading={isPending}
loadingText="Rejecting…"
onClick={() => onConfirm(reason.trim())}
>
Reject uplift
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
export type RevokeDialogProps = {
open: boolean;
amount?: number | null;
woNumber?: string | null;
onClose: () => void;
onConfirm: (reason: string) => void;
isPending?: boolean;
};
function revokeAmountLabel(amount: number | null | undefined): string {
if (amount != null && Number.isFinite(amount)) {
return formatUpliftMoney(amount);
}
return "this uplift";
}
function revokeConfirmLabel(amount: number | null | undefined): string {
if (amount != null && Number.isFinite(amount)) {
return `Revoke ${formatUpliftMoney(amount)}`;
}
return "Revoke";
}
export function RevokeDialog({
open,
amount,
woNumber,
onClose,
onConfirm,
isPending = false,
}: RevokeDialogProps) {
const [reason, setReason] = useState("");
const canConfirm = reason.trim().length > 0 && !isPending;
useEffect(() => {
if (open) {
setReason("");
}
}, [open]);
const handleClose = () => {
if (isPending) return;
setReason("");
onClose();
};
const amountLabel = revokeAmountLabel(amount);
const woSuffix = woNumber ? ` on WO ${woNumber}` : "";
return (
<Dialog
open={open}
onClose={guardDialogClose(isPending, handleClose)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
},
}}
>
<DialogTitle
className="p-6 pb-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
Revoke this approval?
</DialogTitle>
<DialogContent className="px-6 pt-3">
<Text as="p" variant="description" className="mb-3">
The approved uplift of {amountLabel}
{woSuffix} will be withdrawn. This does not recover money already spent — it records that
the authorization was a mistake.
</Text>
<UpliftReasonField
id="revoke-uplift-reason"
label="Reason for revoking"
required
value={reason}
disabled={isPending}
placeholder="Explain why this approval is being withdrawn..."
onChange={setReason}
/>
</DialogContent>
<DialogActions className="px-6 pb-6 pt-2">
<DialogCancelButton onClick={handleClose} disabled={isPending} />
<Button
variant="destructive"
className="min-h-0 px-3 py-1 text-[12px]"
disabled={!canConfirm}
loading={isPending}
loadingText="Revoking…"
onClick={() => onConfirm(reason.trim())}
>
{revokeConfirmLabel(amount)}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,46 @@
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type UpliftReasonFieldProps = {
id: string;
label: string;
value: string;
placeholder: string;
required?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
};
export function UpliftReasonField({
id,
label,
value,
placeholder,
required = false,
disabled = false,
onChange,
}: UpliftReasonFieldProps) {
return (
<div>
<FieldLabel htmlFor={id} required={required}>
{label}
</FieldLabel>
<textarea
id={id}
value={value}
disabled={disabled}
placeholder={placeholder}
required={required}
aria-required={required}
rows={3}
autoFocus
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}

View file

@ -38,6 +38,7 @@ import {
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
@ -297,9 +298,7 @@ function UpliftDecisionDialog({
<DialogTitle>
{action?.type === "approve"
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
: action?.type === "reject"
? "Reject Uplift"
: "Request Changes"}
: "Request Changes"}
</DialogTitle>
<DialogContent>
{action?.type === "approve" && (
@ -354,9 +353,9 @@ export default function UpliftQueuePage() {
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
const noteRequired = genericAction?.type === "request-changes";
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
const handleStatusChange = (value: string) => {
setStatus(value);
@ -382,13 +381,26 @@ export default function UpliftQueuePage() {
};
if (upliftAction.type === "approve") {
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
} else {
return;
}
if (upliftAction.type === "request-changes") {
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
}
};
const handleRejectConfirm = (reason: string) => {
if (upliftAction?.type !== "reject") return;
rejectUplift.mutate(
{ id: upliftAction.item.id, note: reason },
{
onSuccess: () => {
closeDialog();
void refetch();
},
},
);
};
const downloadEvidence = async (item: UpliftQueueItem) => {
try {
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
@ -440,8 +452,15 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<RejectDialog
open={upliftAction?.type === "reject"}
amount={upliftAction?.item.requestedNTE}
onClose={closeDialog}
onConfirm={handleRejectConfirm}
isPending={rejectUplift.isPending}
/>
<UpliftDecisionDialog
action={upliftAction}
action={genericAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}

View file

@ -17,6 +17,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type {
VendorPoDetail,
@ -314,19 +316,22 @@ export function UpliftDecisionDialog({
: "Request Changes"}
</DialogTitle>
<DialogContent>
<FieldLabel htmlFor="vendor-po-uplift-note" required={noteRequired}>
{noteRequired ? "Note" : "Optional note"}
</FieldLabel>
<TextField
id="vendor-po-uplift-note"
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
value={note}
required={noteRequired}
onChange={(e) => onNoteChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<DialogCancelButton onClick={onClose} disabled={decisionPending} />
<Button
variant="contained"
color={action?.type === "approve" ? "success" : "primary"}

View file

@ -16,6 +16,9 @@ import {
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import { useVendorRosterResolver } from "./use-vendor-roster-resolver";
const VENDOR_ROSTER_NO_CHANGES_MESSAGE =
"No changes to save. Enter technician details or update a company field.";
const EDITABLE_COMPANY_FIELDS = [
"name",
"companyPhone",
@ -180,7 +183,7 @@ export function useVendorRosterForm({
if (mode === "create" && selection.selectedRoster != null) {
const selected = selection.selectedRoster;
if (!buildAdditiveRosterPatch(selected, values, selected.rowVersion, editedCompanyFields)) {
toast.error("Enter at least one technician to add to this vendor.");
toast.error(VENDOR_ROSTER_NO_CHANGES_MESSAGE);
return;
}
save.mutate(

View file

@ -0,0 +1,89 @@
import { Controller, useWatch, type Control, type FieldErrors } from "react-hook-form";
import { Stack, TextField } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
const COMPANY_CONTACT_ERROR_ID = "vendor-company-contact-error";
const COMPANY_PHONE_HELPER_ID = "vendor-company-phone-helper";
const COMPANY_EMAIL_HELPER_ID = "vendor-company-email-helper";
interface VendorCompanyContactFieldsProps {
control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>;
}
export function VendorCompanyContactFields({ control, errors }: VendorCompanyContactFieldsProps) {
const companyPhone = useWatch({ control, name: "companyPhone" }) ?? "";
const companyEmail = useWatch({ control, name: "email" }) ?? "";
const showCompanyContactError = Boolean(
errors.companyContact && !companyPhone.trim() && !companyEmail.trim(),
);
const contactDescribedBy = (helperId: string, hasHelper: boolean) =>
[hasHelper ? helperId : null, showCompanyContactError ? COMPANY_CONTACT_ERROR_ID : null]
.filter(Boolean)
.join(" ") || undefined;
return (
<>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="companyPhone"
render={({ field }) => (
<TextField
{...field}
label="Company Phone (optional)"
placeholder="(214) 555-0100"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
slotProps={{
formHelperText: { id: COMPANY_PHONE_HELPER_ID },
htmlInput: {
"aria-describedby": contactDescribedBy(
COMPANY_PHONE_HELPER_ID,
Boolean(errors.companyPhone?.message),
),
},
}}
fullWidth
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextField
{...field}
label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
slotProps={{
formHelperText: { id: COMPANY_EMAIL_HELPER_ID },
htmlInput: {
"aria-describedby": contactDescribedBy(
COMPANY_EMAIL_HELPER_ID,
Boolean(errors.email?.message),
),
},
}}
fullWidth
/>
)}
/>
</Stack>
<Text variant="error" id={COMPANY_CONTACT_ERROR_ID} when={showCompanyContactError}>
{VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE}
</Text>
</>
);
}

View file

@ -54,7 +54,12 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
Register a vendor company and its technicians.
</Text>
</DialogTitle>
<DialogContent sx={{ maxHeight: "55vh" }}>
<DialogContent
sx={{
maxHeight: "55vh",
"&.MuiDialogContent-root": { pt: "12px" },
}}
>
<Stack spacing={2} className="mt-1">
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />

View file

@ -8,6 +8,15 @@ interface VendorRosterConflictAlertProps {
}
export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) {
if (conflict.kind === "duplicate") {
return (
<Alert severity="warning">
<AlertTitle>Company name already exists</AlertTitle>
{conflict.message}
</Alert>
);
}
if (conflict.kind === "stale") {
return (
<Alert

View file

@ -28,6 +28,7 @@ import {
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
import { VendorCompanyContactFields } from "./vendor-company-contact-fields";
interface VendorRosterFormFieldsProps {
control: Control<VendorCompanyRosterFormValues>;
@ -64,9 +65,9 @@ function CompanyNameField({
render={({ field }) => (
<TextField
{...field}
label="Company"
label="Company (required)"
placeholder="Start typing a company…"
required
slotProps={{ htmlInput: { required: true } }}
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
@ -108,12 +109,15 @@ function CompanyNameField({
renderInput={(params) => (
<TextField
{...params}
label="Company"
required
label="Company (required)"
placeholder="Start typing a company…"
error={Boolean(errors.name)}
helperText={errors.name?.message}
inputRef={field.ref}
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required: true },
}}
/>
)}
/>
@ -143,40 +147,7 @@ function CompanyFields({
onSelectCompany={onSelectCompany}
onClearSelectedCompany={onClearSelectedCompany}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="companyPhone"
render={({ field }) => (
<TextField
{...field}
label="Company Phone (optional)"
placeholder="(214) 555-0100"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
fullWidth
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextField
{...field}
label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
fullWidth
/>
)}
/>
</Stack>
<VendorCompanyContactFields control={control} errors={errors} />
<Controller
control={control}
name="address"
@ -387,7 +358,7 @@ function TechniciansFieldArray({
<Box>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Technicians ({fields.length})
Technician details (optional)
</Typography>
<Button
size="small"

View file

@ -51,6 +51,78 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
interface TruncatedTextProps {
value: string;
testId: string;
focusable?: boolean;
href?: string;
color?: string;
variant?: "description";
}
const truncatedTextSx = {
display: "block",
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
function TruncatedText({
value,
testId,
focusable = false,
href,
color = "inherit",
variant,
}: TruncatedTextProps) {
const hasValue = value !== "—";
const content = href ? (
<Link
href={href}
underline="hover"
color="inherit"
data-testid={testId}
sx={{ ...truncatedTextSx, color }}
onClick={stopPropagation}
>
{value}
</Link>
) : variant ? (
<Text
variant={variant}
data-testid={testId}
sx={{ ...truncatedTextSx, color, flex: "1 1 auto" }}
>
{value}
</Text>
) : (
<Box
component="span"
data-testid={testId}
tabIndex={focusable && hasValue ? 0 : undefined}
sx={{ ...truncatedTextSx, color }}
>
{value}
</Box>
);
return (
<Tooltip title={hasValue ? value : ""} placement="top" arrow describeChild>
{content}
</Tooltip>
);
}
function getVendorDisplayValues(row: VendorListItem) {
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
return {
locationText: cityStateText || row.address.trim() || "—",
phoneText: row.phone.trim() || row.companyPhone.trim(),
};
}
interface VendorTableRowProps {
row: VendorListItem;
onOpenDetail: (row: VendorListItem) => void;
@ -61,9 +133,7 @@ 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 cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
const locationText = cityStateText || row.address.trim() || "—";
const phoneText = row.phone.trim() || row.companyPhone.trim();
const { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
const handleKeyDown = (event: KeyboardEvent) => {
@ -84,17 +154,25 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
onClick={() => onOpenDetail(row)}
onKeyDown={handleKeyDown}
>
<TableCell>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.companyName || "—"}
testId="vendor-text-company"
variant="description"
color="var(--color-foreground)"
/>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<TableCell sx={{ overflow: "hidden" }}>
<Stack
direction="row"
spacing={1.25}
sx={{ alignItems: "center", minWidth: 0, overflow: "hidden" }}
>
<Avatar
sx={{
width: 28,
height: 28,
flexShrink: 0,
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-primary)",
fontSize: 10.5,
@ -103,12 +181,22 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
>
{getInitials(row.contactName)}
</Avatar>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
<TruncatedText
value={row.contactName || "—"}
testId="vendor-text-technician"
variant="description"
color="var(--color-foreground)"
/>
</Stack>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={locationText}
testId="vendor-text-location"
focusable
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
@ -133,33 +221,21 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
</Stack>
)}
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{phoneText ? (
<Link
href={`tel:${phoneText}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
{phoneText}
</Link>
) : (
"—"
)}
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}
testId="vendor-text-phone"
href={phoneText ? `tel:${phoneText}` : undefined}
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell>
{row.email ? (
<Link
href={`mailto:${row.email}`}
underline="hover"
sx={{ color: "#6b7a96" }}
onClick={stopPropagation}
>
{row.email}
</Link>
) : (
"—"
)}
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.email || "—"}
testId="vendor-text-email"
href={row.email ? `mailto:${row.email}` : undefined}
color="#6b7a96"
/>
</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>

View file

@ -1,11 +1,16 @@
import { useMemo } from "react";
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
import { useWorkOrderDirectory } from "@/domain/work-orders/use-cases/use-work-orders-list";
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users";
type SlideOverAuditTabProps = {
entries: WorkOrderAuditEntry[];
users?: WorkOrderUser[];
};
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
return <SlideOverAuditList entries={entries} users={users} />;
export function SlideOverAuditTab({ entries, users = [] }: SlideOverAuditTabProps) {
const { data: directory = [] } = useWorkOrderDirectory();
const actors = useMemo(() => mergeWorkOrderUsers(users, directory), [users, directory]);
return <SlideOverAuditList entries={entries} users={actors} />;
}

View file

@ -124,7 +124,7 @@ export function SlideOverInfoTabEditView({
onSave={(dueDate) => onDraftChange({ dueDate })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<SlideOverInfoRow label="Appt Time (optional)">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}

View file

@ -7,6 +7,7 @@ import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import { notifySlideOverSaveSuccess } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import {
isWorkOrderCoreLocked,
isWorkOrderFullyLocked,
@ -116,6 +117,7 @@ export function useSlideOverEditState({
}
onSave(row.id, patch, {
onSuccess: () => {
notifySlideOverSaveSuccess(patch);
setBaseline(activeDraft);
setEditing(false);
setShowUnsaved(false);

View file

@ -1,13 +1,9 @@
import { useState } from "react";
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography,
} from "@mui/material";
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
type UpliftDecisionDialogProps = {
@ -55,27 +51,28 @@ export function UpliftDecisionDialog({
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
<DialogContent>
{type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
<Text as="p" variant="description" className="mt-1 mb-3">
${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
</Typography>
</Text>
)}
<FieldLabel htmlFor="dispatch-uplift-note" required={noteRequired}>
{noteRequired ? "Note" : "Optional note"}
</FieldLabel>
<TextField
id="dispatch-uplift-note"
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
required={noteRequired}
value={note}
required={noteRequired}
onChange={(event) => setNote(event.target.value)}
disabled={isPending}
/>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<DialogCancelButton onClick={handleClose} disabled={isPending} />
<Button
variant={type === "reject" ? "destructive" : "default"}
disabled={noteRequired && !note.trim()}

View file

@ -41,6 +41,7 @@ function indicatorCount(draft: WorkOrderAdvancedFilters): number {
function FlagColorDot({ value }: { value: string }) {
return (
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full"
style={{ background: value, border: "1px solid var(--color-border)" }}
/>

View file

@ -3,10 +3,12 @@ import { Clock } from "lucide-react";
import { Button, Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
calcDuration,
apptTimePopoverHint,
commitApptTimeSelection,
} from "@/domain/work-orders/utils/appt-time-selection";
import {
fmt12,
formatApptRange,
formatApptTimeValue,
parseApptTime,
TIME_SLOTS,
} from "@/domain/work-orders/utils/wizard-date-utils";
@ -17,6 +19,62 @@ type ApptTimeCellProps = {
onSave: (value: string) => void;
};
const SLOT_HEADER_SX = {
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
};
function slotStyle(active: boolean): React.CSSProperties {
return {
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
};
}
function nextStartSelection(
currentStart: string,
currentEnd: string,
slot: string,
): { start: string; end: string } {
if (slot === currentStart) {
return { start: "", end: "" };
}
return {
start: slot,
end: currentEnd && currentEnd <= slot ? "" : currentEnd,
};
}
function ApptTimeSlotList({
slots,
active,
onPick,
}: {
slots: string[];
active: string;
onPick: (slot: string) => void;
}) {
return (
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{slots.map((t) => (
<button
key={t}
type="button"
onClick={() => onPick(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(active === t)}
>
{fmt12(t)}
</button>
))}
</div>
);
}
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
@ -30,16 +88,13 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const parsed = parseApptTime(apptTime);
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
const showPlaceholder = !display || display === "—";
const endSlots = TIME_SLOTS.filter((t) => !start || t > start);
const slotStyle = (active: boolean): React.CSSProperties => ({
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
});
const close = () => setAnchorEl(null);
const done = () => {
onSave(formatApptTimeValue(start, end));
setAnchorEl(null);
commitApptTimeSelection(start, end, apptTime, onSave);
close();
};
return (
@ -53,91 +108,56 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
{display && display !== "—" ? (
{showPlaceholder ? (
<span className="text-[13px] text-[var(--color-text-muted)]">Select appt time …</span>
) : (
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
{display}
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
>
<div className="grid grid-cols-2 border-b border-border">
<div className="border-r border-border">
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
Start
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
onClick={() => {
setStart(t);
if (end && end <= t) {
setEnd("");
}
}}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(start === t)}
>
{fmt12(t)}
</button>
))}
</div>
<ApptTimeSlotList
slots={TIME_SLOTS}
active={start}
onPick={(t) => {
const next = nextStartSelection(start, end, t);
setStart(next.start);
setEnd(next.end);
}}
/>
</div>
<div>
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
End <span className="font-normal normal-case">(optional)</span>
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
<button
key={t}
type="button"
onClick={() => setEnd(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(end === t)}
>
{fmt12(t)}
</button>
))}
</div>
<ApptTimeSlotList
slots={endSlots}
active={end}
onPick={(t) => {
setEnd(t === end ? "" : t);
}}
/>
</div>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-[var(--color-text-muted)]">
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
{apptTimePopoverHint(start, end)}
</span>
<Button size="small" variant="contained" onClick={done} disabled={!start}>
<Button size="small" variant="contained" onClick={done}>
Done
</Button>
</div>

View file

@ -49,7 +49,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
Select …
Select dispatcher
</span>
)}
<HoverChevron />

View file

@ -1,12 +1,24 @@
import type { ReactNode } from "react";
export function FieldLabel({ children }: { children: ReactNode }) {
type FieldLabelProps = {
children: ReactNode;
required?: boolean;
htmlFor?: string;
};
export function FieldLabel({ children, required = false, htmlFor }: FieldLabelProps) {
return (
<label
htmlFor={htmlFor}
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--color-text-secondary)" }}
>
{children}
{Boolean(required) && (
<span aria-hidden style={{ color: "var(--destructive)" }}>
*
</span>
)}
</label>
);
}

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { Flag, X } from "lucide-react";
import { Popover } from "@mui/material";
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
import { FLAG_COLORS, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
type FlagCellProps = {
color?: string | null;
@ -11,11 +11,17 @@ type FlagCellProps = {
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const triggerName = flagTriggerAccessibleName(color);
const popoverOpen = Boolean(anchorEl);
if (disabled) {
return (
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
<Flag size={14} />
<span
role="img"
className="flex h-[22px] w-[22px] items-center justify-center opacity-30"
aria-label={triggerName}
>
<Flag size={14} aria-hidden="true" />
</span>
);
}
@ -24,15 +30,19 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
<>
<button
type="button"
aria-label={triggerName}
aria-haspopup="dialog"
aria-expanded={popoverOpen}
title={triggerName}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
title={color ? "Flagged — click to change" : "Add color flag"}
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
>
<Flag
size={14}
aria-hidden="true"
strokeWidth={color ? 1.5 : 1.75}
style={{
color: color ?? "var(--color-text-muted)",
@ -41,37 +51,48 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
/>
</button>
<Popover
open={Boolean(anchorEl)}
open={popoverOpen}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
slotProps={{
paper: {
className: "rounded-lg border border-border p-2",
role: "dialog",
"aria-label": "Choose flag color",
},
}}
>
<div className="grid grid-cols-4 gap-1.5">
{FLAG_COLORS.map((c) => (
<button
key={c.value}
type="button"
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
>
<span
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow:
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
{FLAG_COLORS.map((c) => {
const selected = color === c.value;
return (
<button
key={c.value}
type="button"
aria-label={c.name}
aria-pressed={selected}
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
/>
</button>
))}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
>
<span
aria-hidden="true"
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow: selected ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
}}
/>
</button>
);
})}
</div>
<button
type="button"
@ -81,7 +102,7 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
}}
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
>
<X size={12} /> Clear flag
<X size={12} aria-hidden="true" /> Clear flag
</button>
</Popover>
</>

View file

@ -1,9 +1,9 @@
import { useMemo } from "react";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
applySchedulePatch,
type WorkOrderTablePatchFn,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
@ -19,7 +19,7 @@ type UseWoTableRowHandlersOptions = {
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onPatch: WorkOrderTablePatchFn;
onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
@ -76,8 +76,8 @@ export function useWoTableRowHandlers({
tableData.setSelectedId(row.id);
onOpenMedia(row);
},
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
onPatch(id, patch);
onPatch: (id, patch, options) => {
onPatch(id, patch, options);
},
onScheduleDate: (id: string | number, date: string) => {
onPatch(id, applySchedulePatch(date, false));

View file

@ -1,6 +1,6 @@
export const WO_TABLE_COLS = [
{ key: "grip", label: "", w: 28 },
{ key: "flag", label: "", w: 28 },
{ key: "flag", label: "", srLabel: "Flag", w: 28 },
{ key: "site", label: "SITE", w: 168 },
{ key: "wo", label: "WO", w: 120 },
{ key: "type", label: "TYPE OF WO", w: 108 },

View file

@ -4,27 +4,32 @@ export function WoTableHead() {
return (
<thead className="sticky top-0 z-10">
<tr>
{WO_TABLE_COLS.map((col) => (
<th
key={col.key}
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
</th>
))}
{WO_TABLE_COLS.map((col) => {
const srLabel = "srLabel" in col ? col.srLabel : undefined;
return (
<th
key={col.key}
scope="col"
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
{Boolean(srLabel) && <span className="sr-only">{srLabel}</span>}
</th>
);
})}
</tr>
</thead>
);

View file

@ -118,7 +118,12 @@ export function WoTableInlineRow({
<td style={{ padding: "0 0 0 6px" }} />
<td style={{ padding: "0 2px 0 8px" }}>
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
<Flag
size={14}
strokeWidth={1.75}
aria-hidden="true"
style={{ color: "var(--color-border)" }}
/>
</td>
<WoTableInlineRowCells

View file

@ -1,6 +1,7 @@
import type { CSSProperties } from "react";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
@ -114,9 +115,7 @@ export function WoTableRowCells({
dispatcherId={row.dispatcherId}
users={users}
highlight={!row.dispatcherId}
onSave={(id, name) =>
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
}
onSave={(id, name) => applyAssignedToTableSave(handlers.onPatch, row.id, id, name)}
/>
</td>
<td

View file

@ -10,7 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@ -77,7 +77,7 @@ export function WoTableRowServiceCells({
vendors={vendors}
q={search}
viewOnly={viewOnly}
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
onSave={(p) => applyVendorTableSave(handlers.onPatch, row.id, p)}
/>
</td>
<td

View file

@ -5,6 +5,7 @@ import type {
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import {
canApplyWorkOrderTablePatch,
@ -29,7 +30,11 @@ export type WoTableRowHandlers = {
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
onPatch: (
id: string | number,
patch: Partial<WorkOrderTableRow>,
options?: WorkOrderTablePatchOptions,
) => void;
onScheduleDate: (id: string | number, date: string) => void;
onScheduleWeek: (id: string | number, week: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -84,9 +89,9 @@ export function WoTableRow({
const guardedHandlers: WoTableRowHandlers = {
...handlers,
onPatch: (id, patch) => {
onPatch: (id, patch, options) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(id, patch);
handlers.onPatch(id, patch, options);
},
onScheduleDate: (id, date) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;

View file

@ -5,10 +5,8 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderTablePatchFn } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
@ -39,7 +37,7 @@ type WoTableProps = {
onOpenMedia: (row: WorkOrderTableRow) => void;
onClearSearch?: () => void;
onOpenAdvancedFilters?: () => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onPatch: WorkOrderTablePatchFn;
};
type WoTableEmptyShellProps = {

View file

@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
type RevokeUpliftDialogProps = {
open: boolean;
@ -38,6 +35,7 @@ export function RevokeUpliftDialog({
}, [open]);
const handleClose = () => {
if (pending) return;
onClose();
};
@ -48,27 +46,30 @@ export function RevokeUpliftDialog({
const reasonMissing = requireReason && reason.trim() === "";
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Text as="p" variant="description" className="mb-3">
{description}
</Text>
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
Reason
</FieldLabel>
<TextField
label={requireReason ? "Reason (required)" : "Reason (optional)"}
id="wo-revoke-uplift-reason"
fullWidth
multiline
minRows={2}
required={requireReason}
value={reason}
required={requireReason}
onChange={(event) => setReason(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<DialogCancelButton onClick={handleClose} disabled={pending} />
<Button
color="error"
variant="contained"
variant="destructive"
className="min-h-0 px-3 py-1 text-[12px]"
disabled={pending || reasonMissing}
onClick={handleConfirm}
>

View file

@ -298,7 +298,7 @@ export function WizardStepTypeSchedule({
<WizLabel optional>Assigned To</WizLabel>
<WizardFieldSelect
value={draft.assigneeId}
placeholder="Select…"
placeholder="Select dispatcher"
icon={
selectedUser ? (
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />

View file

@ -8,6 +8,10 @@ import {
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import {
patchWorkOrderAsCanceled,
patchWorkOrderAsCompleted,
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
@ -82,14 +86,14 @@ export function useWorkOrdersListPage() {
const handleConfirmCancel = () => {
if (!confirmCancel) return;
patchField(confirmCancel.id, { status: "Canceled" });
patchWorkOrderAsCanceled(patchField, confirmCancel);
setConfirmCancel(null);
handleCloseSlideOver();
};
const handleConfirmComplete = () => {
if (!confirmComplete) return;
patchField(confirmComplete.id, { status: "Completed" });
patchWorkOrderAsCompleted(patchField, confirmComplete.id);
setConfirmComplete(null);
};

View file

@ -0,0 +1,20 @@
import { Button, type UiButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/** Same tokens as VendorDialogFooter Cancel (`ghost` + this class). */
export const VENDOR_DIALOG_CANCEL_CLASS = "min-h-0 px-2 py-1 text-[12px]";
type DialogCancelButtonProps = Omit<UiButtonProps, "variant">;
/** Cancel chrome shared with VendorDialog footer. */
export function DialogCancelButton({
className,
children = "Cancel",
...props
}: DialogCancelButtonProps) {
return (
<Button variant="ghost" className={cn(VENDOR_DIALOG_CANCEL_CLASS, className)} {...props}>
{children}
</Button>
);
}

View file

@ -19,6 +19,7 @@ const OPEN_WORK_ORDER_MESSAGE =
async function readConflictBody(error: unknown): Promise<unknown> {
if (!isHTTPError(error)) return undefined;
if (error.data !== undefined && error.data !== null) return error.data;
try {
return await error.response.json();
} catch {

View file

@ -179,8 +179,13 @@ export function mapRosterConflict(raw: unknown, fallbackMessage: string): Vendor
const item = asRecord(raw);
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
const code = readString(item, "code", "Code");
const kind: VendorRosterConflictKind =
blockedWorkOrders.length > 0 ? "open-work-orders" : "stale";
code === "duplicate_vendor_company_name"
? "duplicate"
: blockedWorkOrders.length > 0
? "open-work-orders"
: "stale";
const message = readString(item, "message", "Message") || fallbackMessage;
return { kind, message, blockedWorkOrders };
}

View file

@ -51,6 +51,10 @@ export type RosterTechnicianValues = z.infer<typeof rosterTechnicianSchema>;
const baseCompanyFields = {
name: z.string().trim().min(1, "Company is required"),
// Validation-only identity for the phone-or-email group. It is never
// registered as an input and Zod omits it from parsed values when absent,
// so it cannot enter the vendor payload.
companyContact: z.never().optional(),
companyPhone: northAmericanPhone,
email: optionalEmail,
address: z.string(),
@ -64,14 +68,20 @@ const baseCompanyFields = {
technicians: z.array(rosterTechnicianSchema),
};
export const VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE =
"Provide a company phone or email (at least one required)";
// The phone-or-email rule spans two optional inputs, so it is reported at a
// validation-only path instead of being attributed to `companyPhone`, which
// would mark that input individually invalid.
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
const hasEmail = Boolean(data.email && data.email.trim());
if (!hasPhone && !hasEmail) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["companyPhone"],
message: "Provide a company phone or email (at least one required)",
path: ["companyContact"],
message: VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
});
}
});

View file

@ -145,7 +145,7 @@ export interface VendorRosterBlockedWorkOrder {
scheduledDate?: string;
}
export type VendorRosterConflictKind = "open-work-orders" | "stale";
export type VendorRosterConflictKind = "duplicate" | "open-work-orders" | "stale";
export interface VendorRosterConflict {
kind: VendorRosterConflictKind;

View file

@ -110,18 +110,34 @@ function toUrlSearchParams(
return sp;
}
function readOptionalString(item: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = item[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
/** GET User serializes `Name` as FirstName + LastName; compose those fields if present. */
function mapUserDisplayName(item: Record<string, unknown>): string {
const firstName = readOptionalString(item, "firstName", "FirstName");
const lastName = readOptionalString(item, "lastName", "LastName");
const fallback = readOptionalString(item, "name", "Name", "userName", "UserName");
if (lastName) {
return `${firstName || fallback} ${lastName}`.trim();
}
return firstName || fallback;
}
function mapUser(raw: unknown): WorkOrderUser {
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const id = item.id ?? item.Id;
const name =
(typeof item.name === "string" ? item.name : undefined) ??
(typeof item.Name === "string" ? item.Name : undefined) ??
(typeof item.userName === "string" ? item.userName : "") ??
"";
return {
id: typeof id === "string" || typeof id === "number" ? id : "",
name,
name: mapUserDisplayName(item),
};
}

View file

@ -11,3 +11,23 @@ export const FLAG_COLORS: { name: string; value: string }[] = [
];
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
export function flagColorName(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
return FLAG_COLORS.find((color) => color.value.toLowerCase() === normalized)?.name ?? null;
}
/** Accessible trigger name — never color-only. */
export function flagTriggerAccessibleName(color: string | null | undefined): string {
const name = flagColorName(color);
if (name) {
return `${name} flag`;
}
if (color) {
return "Flagged";
}
return "Add flag";
}

View file

@ -2,7 +2,10 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils";
/** Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. */
/**
* Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time.
* Board create rejects appointment fields without vendorId (`DispatchRequired`).
*/
export function assignWizardVendorApptFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,

View file

@ -84,7 +84,7 @@ export function buildBoardPatchPayload(
if (row.dispatchRowVersion) {
payload.dispatchVersion = row.dispatchRowVersion;
}
if (row.primaryDispatchId != null) {
if (row.primaryDispatchId != null && row.primaryDispatchId > 0) {
payload.primaryDispatchId = row.primaryDispatchId;
}
}

View file

@ -24,6 +24,17 @@ import { queryKeys } from "@/infra/query-key/query-key";
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
export type WorkOrderTablePatchFn = (
id: string | number,
patch: WorkOrderTablePatch,
options?: WorkOrderTablePatchOptions,
) => void;
export type WorkOrderTablePatchHandlers = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
clearPatch: (id: string | number) => void;
@ -112,6 +123,7 @@ export function useWorkOrderTableMutations(
};
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
@ -146,20 +158,10 @@ export function useWorkOrderTableMutations(
},
});
const patchField = (
id: string | number,
patch: WorkOrderTablePatch,
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
},
) => {
patchMutation.mutate(
{ id, patch },
{
onSuccess: () => options?.onSuccess?.(),
onError: (error) => options?.onError?.(error),
},
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
void patchMutation.mutateAsync({ id, patch }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};

View file

@ -54,6 +54,15 @@ export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
});
}
/** Full user directory for audit actor names (Schedulers are not on the dispatcher lookup). */
export function useWorkOrderDirectory(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.directory(),
queryFn: () => workOrdersApi.fetchUsers(),
staleTime: 5 * 60 * 1000,
});
}
export function useWorkOrdersByLocation(
locationId: string | number | undefined,
search = "",

View file

@ -0,0 +1,33 @@
import {
calcDuration,
formatApptTimeValue,
parseApptTime,
} from "@/domain/work-orders/utils/wizard-date-utils";
export function apptTimePopoverHint(start: string, end: string): string {
if (start && end) {
const duration = calcDuration(start, end);
return duration ? `Duration: ${duration}` : "—";
}
if (start) {
return "Pick end (optional)";
}
return "Pick a start time";
}
export function commitApptTimeSelection(
start: string,
end: string,
apptTime: string,
onSave: (value: string) => void,
): void {
if (start) {
onSave(formatApptTimeValue(start, end));
return;
}
const parsed = parseApptTime(apptTime);
const isRawUnparsed = Boolean(apptTime.trim()) && !parsed.start;
if (!isRawUnparsed) {
onSave("");
}
}

View file

@ -98,6 +98,24 @@ function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string
return "Work order created in the system.";
}
function tryAssignedToRule({
fieldKey,
actionKey,
newValue,
}: AuditActionRuleContext): string | null {
const matches =
fieldKey === "assignto" ||
fieldKey === "assignedto" ||
fieldKey === "assigneeid" ||
fieldKey === "dispatcherid" ||
actionKey === "assignmentchanged" ||
actionKey.includes("changeassignment");
if (!matches) return null;
const value = newValue.text ? wrapAuditValue(newValue) : '"—"';
return `Changed Assigned To to ${value}`;
}
/** Applied in order; the first non-null result wins. */
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
tryStatusRule,
@ -105,5 +123,6 @@ export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
tryScheduleRule,
tryCarriedOverRule,
tryPastDueRule,
tryAssignedToRule,
tryCreatedRule,
];

View file

@ -0,0 +1,18 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
/** Primary list wins on id collision (dispatcher lookup before directory). */
export function mergeWorkOrderUsers(
primary: WorkOrderUser[],
extra: WorkOrderUser[],
): WorkOrderUser[] {
const byId = new Map<string, WorkOrderUser>();
for (const user of primary) {
const key = String(user.id);
if (key) byId.set(key, user);
}
for (const user of extra) {
const key = String(user.id);
if (key && !byId.has(key)) byId.set(key, user);
}
return [...byId.values()];
}

View file

@ -151,6 +151,13 @@ export function parseApptTime(appt: string): { start: string; end: string } {
}
return { start: `${pad2(h)}:${single[2]}`, end: "" };
}
const startOnly = appt.trim().match(/^(\d{1,2}):(\d{2})$/);
if (startOnly) {
return {
start: `${startOnly[1].padStart(2, "0")}:${startOnly[2]}`,
end: "",
};
}
return { start: "", end: "" };
}

View file

@ -0,0 +1,91 @@
import { toast } from "react-toastify";
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
import type { VendorAssignmentFields } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
type PersistPatchFn = (
id: string | number,
patch: WorkOrderTablePatch,
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
},
) => void;
export function workOrderCanceledMessage(woNumber: string) {
return `Work order #${woNumber} canceled.`;
}
export function vendorAssignedMessage() {
return "Vendor assigned";
}
export function workOrderSavedMessage() {
return "Work order saved successfully";
}
export function notifyWorkOrderCanceled(woNumber: string) {
toast.warning(workOrderCanceledMessage(woNumber));
}
export function notifyVendorAssigned() {
toast.success(vendorAssignedMessage());
}
export function notifyWorkOrderSaved() {
toast.success(workOrderSavedMessage());
}
function isAssignedVendorId(vendorId: unknown): boolean {
return vendorId != null && String(vendorId).trim() !== "";
}
export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean {
return (
Object.prototype.hasOwnProperty.call(patch, "vendorId") && isAssignedVendorId(patch.vendorId)
);
}
export function notifySlideOverSaveSuccess(patch: WorkOrderTablePatch) {
if (isVendorAssignmentPatch(patch)) {
notifyVendorAssigned();
return;
}
notifyWorkOrderSaved();
}
export function patchWorkOrderAsCanceled(
patchField: PersistPatchFn,
row: { id: string | number; woNumber: string },
) {
patchField(
row.id,
{ status: "Canceled" },
{
onSuccess: () => notifyWorkOrderCanceled(row.woNumber),
},
);
}
export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) {
patchField(id, { status: "Completed" });
}
export function applyVendorTableSave(
onPatch: PersistPatchFn,
id: string | number,
fields: VendorAssignmentFields,
) {
onPatch(id, toVendorTablePatch(fields), {
onSuccess: isAssignedVendorId(fields.vendorId) ? notifyVendorAssigned : notifyWorkOrderSaved,
});
}
export function applyAssignedToTableSave(
onPatch: PersistPatchFn,
id: string | number,
dispatcherId: string,
dispatcherName: string,
) {
onPatch(id, { dispatcherId, dispatcherName }, { onSuccess: notifyWorkOrderSaved });
}

View file

@ -25,6 +25,7 @@ export const queryKeys = {
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
directory: () => [...queryKeys.workOrders.all, "directory"] as const,
uplifts: (workOrderId: string | number) =>
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
},

View file

@ -0,0 +1,149 @@
import type { ComponentProps } from "react";
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import {
DialogCancelButton,
VENDOR_DIALOG_CANCEL_CLASS,
} from "@/components/ui/dialog-cancel-button";
import { renderWithProviders } from "@/test/test-utils";
const VENDOR_CANCEL_TOKENS = VENDOR_DIALOG_CANCEL_CLASS.split(" ");
function expectVendorCancelChrome(button: HTMLElement) {
for (const token of VENDOR_CANCEL_TOKENS) {
expect(button).toHaveClass(token);
}
expect(button).toHaveClass("text-foreground");
}
function expectDestructiveAsterisk(labelText: string) {
const label = screen.getByText(labelText).closest("label");
expect(label).toBeTruthy();
const star = within(label as HTMLElement).getByText("*");
expect(star).toHaveAttribute("aria-hidden", "true");
expect(star).toHaveStyle({ color: "var(--destructive)" });
}
function renderReject(overrides: Partial<ComponentProps<typeof RejectDialog>> = {}) {
const onClose = vi.fn();
const onConfirm = vi.fn();
renderWithProviders(
<RejectDialog open amount={250} onClose={onClose} onConfirm={onConfirm} {...overrides} />,
);
return { onClose, onConfirm };
}
function renderRevoke(overrides: Partial<ComponentProps<typeof RevokeDialog>> = {}) {
const onClose = vi.fn();
const onConfirm = vi.fn();
renderWithProviders(
<RevokeDialog open amount={500} onClose={onClose} onConfirm={onConfirm} {...overrides} />,
);
return { onClose, onConfirm };
}
describe("FieldLabel required asterisk", () => {
it("renders a destructive asterisk only when required", () => {
const { rerender } = renderWithProviders(<FieldLabel>Site</FieldLabel>);
expect(screen.getByText("Site").textContent).toBe("Site");
expect(screen.queryByText("*")).not.toBeInTheDocument();
rerender(<FieldLabel required>Site</FieldLabel>);
expectDestructiveAsterisk("Site");
});
});
describe("DialogCancelButton VendorDialog tokens", () => {
it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => {
renderWithProviders(
<>
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} />
<DialogCancelButton />
</>,
);
const [vendorCancel, sharedCancel] = screen.getAllByRole("button", { name: "Cancel" });
expectVendorCancelChrome(vendorCancel);
expectVendorCancelChrome(sharedCancel);
});
});
describe("RejectDialog", () => {
it("keeps confirm disabled until a non-whitespace reason is typed", () => {
const { onConfirm } = renderReject();
const confirm = screen.getByRole("button", { name: "Reject uplift" });
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
target: { value: " " },
});
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
target: { value: "Over the NTE cap" },
});
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
expect(onConfirm).toHaveBeenCalledWith("Over the NTE cap");
});
it("closes on Cancel without confirming", () => {
const { onClose, onConfirm } = renderReject();
const cancel = screen.getByRole("button", { name: "Cancel" });
expectVendorCancelChrome(cancel);
fireEvent.click(cancel);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it("marks the reason as required with an asterisk", () => {
renderReject();
expect(screen.getByRole("heading", { name: "Reject uplift of $250.00?" })).toBeInTheDocument();
expectDestructiveAsterisk("Reason for rejection");
const reason = screen.getByLabelText(/reason for rejection/i);
expect(reason).toBeRequired();
expect(reason).toHaveAttribute("aria-required", "true");
});
it("blocks cancel and escape while pending", () => {
const { onClose } = renderReject({ isPending: true });
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
describe("RevokeDialog", () => {
it("keeps confirm disabled until a reason is typed", () => {
const { onConfirm } = renderRevoke();
const confirm = screen.getByRole("button", { name: "Revoke $500.00" });
expect(confirm).toBeDisabled();
fireEvent.change(screen.getByLabelText(/reason for revoking/i), {
target: { value: "Approved in error" },
});
fireEvent.click(confirm);
expect(onConfirm).toHaveBeenCalledWith("Approved in error");
});
it("closes on Cancel without confirming", () => {
const { onClose, onConfirm } = renderRevoke();
const cancel = screen.getByRole("button", { name: "Cancel" });
expectVendorCancelChrome(cancel);
fireEvent.click(cancel);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it("marks the reason as required with an asterisk", () => {
renderRevoke();
expect(screen.getByRole("button", { name: "Revoke $500.00" })).toBeInTheDocument();
expectDestructiveAsterisk("Reason for revoking");
const reason = screen.getByLabelText(/reason for revoking/i);
expect(reason).toBeRequired();
expect(reason).toHaveAttribute("aria-required", "true");
});
});

View file

@ -0,0 +1,81 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const rejectMutate = vi.fn();
const approveMutate = vi.fn();
const requestChangesMutate = vi.fn();
const refetch = vi.fn();
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "Extra parts",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
};
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 },
isLoading: false,
isFetching: false,
error: null,
refetch,
}),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }),
}));
describe("Uplift queue decision dialogs", () => {
beforeEach(() => {
rejectMutate.mockReset();
approveMutate.mockReset();
requestChangesMutate.mockReset();
refetch.mockReset();
});
it("opens RejectDialog from Reject and does not reject on Cancel", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(rejectMutate).not.toHaveBeenCalled();
});
it("keeps Approve on the generic confirm dialog", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject uplift" })).not.toBeInTheDocument();
});
});

View file

@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useController, useFieldArray } from "react-hook-form";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
@ -428,6 +429,7 @@ describe("useVendorRosterForm additive add flow (SH-250)", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
vi.mocked(toast.error).mockReset();
});
it("issues an additive add — never update — when an existing company is selected", async () => {
@ -498,6 +500,10 @@ describe("useVendorRosterForm additive add flow (SH-250)", () => {
});
expect(saveMutate).not.toHaveBeenCalled();
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"No changes to save. Enter technician details or update a company field.",
);
});
it("keeps the entered technician and retries with the fresh rowVersion after reload", async () => {

View file

@ -1,7 +1,16 @@
import { fireEvent, screen, waitFor } 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 { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyVendorCompanyRosterForm,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
@ -18,28 +27,136 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
};
});
function PlainCompanyFieldsHarness() {
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(() => {})}>
<VendorRosterFormFields control={control} errors={formState.errors} />
</form>
);
}
function renderCreateModal() {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
}
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
});
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
}
describe("VendorCreateModal validation", () => {
it("explains the company phone-or-email requirement after submission", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
renderCreateModal();
await fillCompanyNameAndSubmit();
fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), {
target: { value: "Gateway Plumbing" },
});
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
});
it("labels the autocomplete company field exactly, required, with no generated asterisk", () => {
renderCreateModal();
const company = screen.getByRole("combobox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
/^Company \(required\)$/,
);
});
it("labels the optional technician section without counting blank drafts", () => {
renderCreateModal();
expect(screen.getByText("Technician details (optional)")).toBeInTheDocument();
expect(screen.queryByText("Technicians (1)")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Technician name (optional)" })).toBeVisible();
});
it("labels the plain company field exactly, required, with no generated asterisk", () => {
renderWithProviders(<PlainCompanyFieldsHarness />, { route: "/vendors", withAuth: false });
const company = screen.getByRole("textbox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
/^Company \(required\)$/,
);
});
it("shows the phone-or-email error once without marking either input individually invalid", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const message = await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE);
expect(message).toHaveAttribute("id");
expect(screen.getAllByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toHaveLength(1);
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
expect(phone).not.toHaveAttribute("aria-invalid", "true");
expect(email).not.toHaveAttribute("aria-invalid", "true");
expect(phone.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
expect(email.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
});
it("marks only the phone input invalid when the phone is malformed", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(phone, "314");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
).toBeInTheDocument();
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
expect(phone).toHaveAttribute("aria-invalid", "true");
expect(email).not.toHaveAttribute("aria-invalid", "true");
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
});
it("marks only the email input invalid when the email is malformed", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(email, "not-an-email");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
expect(email).toHaveAttribute("aria-invalid", "true");
expect(phone).not.toHaveAttribute("aria-invalid", "true");
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
});
it("clears the phone-or-email error once a valid phone is entered", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
await userEvent.type(phone, "3145550100");
await waitFor(() =>
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
);
});
it("shows the notes limit and live character counter", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
renderCreateModal();
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
fireEvent.change(notes, { target: { value: "abc" } });

View file

@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { VendorRosterConflictAlert } from "@/app/(protected)/vendors/_components/vendor-roster-conflict-alert";
describe("VendorRosterConflictAlert", () => {
it("explains a duplicate company without offering a stale-data reload", () => {
render(
<VendorRosterConflictAlert
conflict={{
kind: "duplicate",
message: "Another vendor company already uses that name.",
blockedWorkOrders: [],
}}
onReload={vi.fn()}
/>,
);
expect(screen.getByText("Company name already exists")).toBeInTheDocument();
expect(screen.getByText("Another vendor company already uses that name.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reload" })).not.toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import { screen, within } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import VendorsListPage from "@/app/(protected)/vendors/index";
@ -181,6 +181,69 @@ describe("VendorsListPage", () => {
expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument();
});
it("truncates every textual table field and reveals each full value", async () => {
setupDefaults();
const longCompany = "Gateway Plumbing and Water Systems of Greater Saint Louis";
const longTechnician = "Adam Christopher Whyte Senior Field Technician";
const longAddress = "New York, NY, Estados Unidos, 123 Long Address Avenue";
const longPhone = "+1 (314) 555-0198 extension 123456";
const longEmail = "dispatch-and-after-hours-coordination@gatewayplumbing.example";
const fallbackVendor: VendorListItem = {
...activeVendor,
id: 4,
companyName: longCompany,
contactName: longTechnician,
phone: longPhone,
email: longEmail,
address: longAddress,
city: "",
state: "",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([fallbackVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const fields = [
{ testId: "vendor-text-company", value: longCompany },
{ testId: "vendor-text-technician", value: longTechnician },
{ testId: "vendor-text-location", value: longAddress },
{ testId: "vendor-text-phone", value: longPhone },
{ testId: "vendor-text-email", value: longEmail },
];
for (const field of fields) {
const text = screen.getByTestId(field.testId);
const cell = text.closest("td");
expect(cell).not.toBeNull();
expect(cell).toHaveStyle({ overflow: "hidden" });
expect(text).toHaveStyle({
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
await userEvent.hover(text);
expect(await screen.findByRole("tooltip")).toHaveTextContent(field.value);
await userEvent.unhover(text);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
}
expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`);
expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`);
const locationText = screen.getByTestId("vendor-text-location");
fireEvent.keyDown(document, { key: "Tab" });
locationText.focus();
expect(locationText).toHaveFocus();
expect(await screen.findByRole("tooltip")).toHaveTextContent(longAddress);
expect(within(screen.getByRole("cell", { name: longAddress })).getByText(longAddress)).toBe(
locationText,
);
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
@ -329,7 +392,7 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
const company = screen.getByRole("textbox", { name: "Company" });
const company = screen.getByRole("textbox", { name: "Company (required)" });
await userEvent.clear(company);
await userEvent.type(company, "Draft Company Name");
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
@ -362,7 +425,9 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
"Gateway Plumbing",
);
});
it("opens inline company edit when the row has no vendor id", async () => {
@ -385,6 +450,8 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
"Gateway Plumbing",
);
});
});

View file

@ -0,0 +1,140 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import {
apptTimePopoverHint,
commitApptTimeSelection,
} from "@/domain/work-orders/utils/appt-time-selection";
import { fmt12, formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
describe("apptTimePopoverHint", () => {
it("asks for start when empty", () => {
expect(apptTimePopoverHint("", "")).toBe("Pick a start time");
});
it("marks end as optional after start", () => {
expect(apptTimePopoverHint("08:00", "")).toBe("Pick end (optional)");
});
it("prefixes duration when both ends are set", () => {
expect(apptTimePopoverHint("08:00", "10:00")).toBe("Duration: 2h");
});
});
describe("commitApptTimeSelection", () => {
it("saves an empty string when start is empty and the current value parses empty", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "", onSave);
expect(onSave).toHaveBeenCalledWith("");
});
it("does not clear a raw unparsed appointment label", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "AM before 2pm", onSave);
expect(onSave).not.toHaveBeenCalled();
});
it("clears a stored start-only time when Start is empty", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "09:00", onSave);
expect(onSave).toHaveBeenCalledWith("");
});
it("saves start only when end is omitted", () => {
const onSave = vi.fn();
commitApptTimeSelection("09:00", "", "", onSave);
expect(onSave).toHaveBeenCalledWith("09:00");
});
it("saves an en-dash range when both slots are set", () => {
const onSave = vi.fn();
commitApptTimeSelection("09:00", "11:00", "", onSave);
expect(onSave).toHaveBeenCalledWith("09:00 – 11:00");
});
});
describe("ApptTimeCell", () => {
it("shows the AAP empty placeholder", () => {
render(<ApptTimeCell apptTime="" onSave={vi.fn()} />);
expect(screen.getByRole("button", { name: /select appt time/i })).toBeInTheDocument();
});
it("lets Done confirm an empty selection", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select appt time/i }));
const done = await screen.findByRole("button", { name: /^done$/i });
expect(done).toBeEnabled();
expect(screen.getByText("Pick a start time")).toBeInTheDocument();
await user.click(done);
expect(onSave).toHaveBeenCalledWith("");
});
it("saves start and optional end", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select appt time/i }));
const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") });
await user.click(startSlots[0]!);
const endSlots = screen.getAllByRole("button", { name: fmt12("11:00") });
await user.click(endSlots[endSlots.length - 1]!);
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("09:00 – 11:00");
});
it("keeps an unparsed label when Done is pressed without a start slot", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="AM before 2pm" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /am before 2pm/i }));
await user.click(await screen.findByRole("button", { name: /^done$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it("clears optional end when the selected end slot is toggled off", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") }));
const endSlots = await screen.findAllByRole("button", { name: fmt12("11:00") });
await user.click(endSlots[endSlots.length - 1]!);
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("09:00");
});
it("clears start and end when the selected start slot is toggled off", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") }));
const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") });
await user.click(startSlots[0]!);
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("");
});
it("hydrates start-only stored times so Done can clear them", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: fmt12("09:00") }));
expect(await screen.findByText("Pick end (optional)")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("09:00");
});
});

View file

@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
const USERS = [
{ id: "sarah", name: "Sarah Chen" },
{ id: "morgan", name: "Morgan Blake" },
];
describe("AssignedToCell", () => {
it("lets any board user pick a dispatcher without a role/disabled prop", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<AssignedToCell dispatcherId="" users={USERS} highlight onSave={onSave} />);
const trigger = screen.getByRole("button", { name: /select dispatcher/i });
expect(trigger).toBeEnabled();
expect(trigger).not.toHaveAttribute("aria-disabled", "true");
await user.click(trigger);
await user.click(await screen.findByRole("button", { name: /sarah chen/i }));
expect(onSave).toHaveBeenCalledWith("sarah", "Sarah Chen");
});
it("can clear assignment to Unassigned", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<AssignedToCell dispatcherId="sarah" users={USERS} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /sarah chen/i }));
await user.click(await screen.findByRole("button", { name: "Unassigned" }));
expect(onSave).toHaveBeenCalledWith("", "");
});
it("still opens with an empty dispatcher list", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<AssignedToCell dispatcherId="" users={[]} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select dispatcher/i }));
await user.click(await screen.findByRole("button", { name: "Unassigned" }));
expect(onSave).toHaveBeenCalledWith("", "");
});
});

View file

@ -54,4 +54,42 @@ describe("buildInlineDraftCreateInput", () => {
expect(payload.targetWeek).toBe("2026-06-22");
expect(payload.scheduledDate).toBeUndefined();
});
it("sends assignTo when a dispatcher is selected at inline create", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25003",
pocName: "Jane",
pocPhone: "555-0100",
dispatcherId: "sarah",
};
const users = [{ id: "sarah", name: "Sarah Chen" }];
const input = buildInlineDraftCreateInput(draft, users, [{ id: 9, name: "BK5 · Dallas, TX" }]);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(input.assigneeId).toBe("sarah");
expect(input.assigneeName).toBe("Sarah Chen");
expect(payload.assignTo).toBe("sarah");
});
it("omits assignTo when Assigned To is left unassigned", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25004",
pocName: "Jane",
pocPhone: "555-0100",
dispatcherId: "",
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(input.assigneeId).toBe("");
expect(payload).not.toHaveProperty("assignTo");
});
});

View file

@ -0,0 +1,59 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FlagCell } from "@/app/(protected)/workorders/_components/list/table/cells/flag-cell";
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
function flagPickerDialog() {
return screen.getByRole("dialog", { name: "Choose flag color" });
}
describe("FlagCell", () => {
it("sets, changes, and clears a flag by named controls", () => {
const onChange = vi.fn();
const { rerender } = render(<FlagCell color={null} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Red" }));
expect(onChange).toHaveBeenCalledWith("#EF4444");
expect(screen.queryByRole("dialog", { name: "Choose flag color" })).toBeNull();
rerender(<FlagCell color="#EF4444" onChange={onChange} />);
expect(screen.getByRole("button", { name: "Red flag" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Red flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Blue" }));
expect(onChange).toHaveBeenCalledWith("#3B82F6");
rerender(<FlagCell color="#3B82F6" onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Blue flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Clear flag" }));
expect(onChange).toHaveBeenCalledWith(null);
expect(screen.queryByRole("dialog", { name: "Choose flag color" })).toBeNull();
});
it("exposes a non-color name when the hex is not in the palette", () => {
render(<FlagCell color="#000000" onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "Flagged" })).toBeInTheDocument();
});
it("names a disabled flag without exposing a button", () => {
const onChange = vi.fn();
render(<FlagCell color="#EF4444" disabled onChange={onChange} />);
expect(screen.getByRole("img", { name: "Red flag" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Red flag" })).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
});
describe("WoTableHead flag column", () => {
it("exposes a screen-reader label for the flag column", () => {
render(
<table>
<WoTableHead />
</table>,
);
expect(screen.getByRole("columnheader", { name: "Flag" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
const ACTOR = { id: "actor-1", name: "Alex Dispatcher" };
function assignmentEntry(overrides: Partial<WorkOrderAuditEntry> = {}): WorkOrderAuditEntry {
return {
id: "a1",
fieldName: "AssignTo",
oldValue: "Unassigned",
newValue: "Morgan Blake",
action: "AssignmentChanged",
createdAt: "2026-06-01T13:10:00.000Z",
userName: "",
type: "manual",
dispatcherId: ACTOR.id,
...overrides,
};
}
describe("SlideOverAuditList assignment actor", () => {
it("names who changed Assigned To from dispatcherId on the users list", () => {
render(<SlideOverAuditList entries={[assignmentEntry()]} users={[ACTOR]} />);
expect(screen.getByText("Alex Dispatcher")).toBeInTheDocument();
expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument();
});
it("falls back to userName when dispatcherId is not in the users list", () => {
render(
<SlideOverAuditList
entries={[assignmentEntry({ dispatcherId: "missing", userName: "Pat Scheduler" })]}
users={[ACTOR]}
/>,
);
expect(screen.getByText("Pat Scheduler")).toBeInTheDocument();
expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,47 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const fetchUsers = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
fetchUsers: (...args: unknown[]) => fetchUsers(...args),
},
}));
const DISPATCHER = { id: "d1", name: "Alex Dispatcher" };
const SCHEDULER = { id: "s1", name: "Pat Scheduler" };
const entry: WorkOrderAuditEntry = {
id: "a1",
fieldName: "AssignTo",
oldValue: "Unassigned",
newValue: "Morgan Blake",
action: "AssignmentChanged",
createdAt: "2026-06-01T13:10:00.000Z",
userName: "",
type: "manual",
dispatcherId: SCHEDULER.id,
};
describe("SlideOverAuditTab assignment actor", () => {
beforeEach(() => {
fetchUsers.mockReset();
fetchUsers.mockResolvedValue([SCHEDULER]);
});
it("names a scheduler actor who is not on the dispatcher lookup", async () => {
renderWithProviders(<SlideOverAuditTab entries={[entry]} users={[DISPATCHER]} />, {
withAuth: false,
});
await waitFor(() => {
expect(screen.getByText("Pat Scheduler")).toBeInTheDocument();
});
expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument();
expect(screen.queryByText("s1")).not.toBeInTheDocument();
});
});

View file

@ -138,6 +138,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => {
{ withAuth: false },
);
expect(screen.getByText("Appt Time (optional)")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /save site 1/i }));

View file

@ -1,7 +1,20 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
vendorAssignedMessage,
workOrderSavedMessage,
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
vi.mock("react-toastify", () => ({
toast: {
warning: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
@ -56,6 +69,10 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
}
describe("useSlideOverEditState completed lock", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("leaves edit mode and refuses commit when the same work order becomes completed", () => {
const onSave = vi.fn();
const onClose = vi.fn();
@ -93,4 +110,68 @@ describe("useSlideOverEditState completed lock", () => {
expect(onSave).not.toHaveBeenCalled();
});
it("toasts vendor assigned after a successful save that includes vendorId", () => {
const onSave = vi.fn();
const open = baseRow();
const { result } = renderHook(() =>
useSlideOverEditState({
row: open,
editMode: true,
infoSource: open,
onSave,
onClose: vi.fn(),
closeDisabled: false,
setTab: vi.fn(),
}),
);
act(() => {
result.current.handleDraftChange({ vendorId: "v2", company: "New Co", tech: "Lee" });
});
act(() => {
result.current.commit();
});
expect(onSave).toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
const options = onSave.mock.calls[0][2] as { onSuccess: () => void };
act(() => {
options.onSuccess();
});
expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage());
expect(toast.success).toHaveBeenCalledTimes(1);
});
it("toasts generic save after a successful non-vendor slide-over patch", () => {
const onSave = vi.fn();
const open = baseRow();
const { result } = renderHook(() =>
useSlideOverEditState({
row: open,
editMode: true,
infoSource: open,
onSave,
onClose: vi.fn(),
closeDisabled: false,
setTab: vi.fn(),
}),
);
act(() => {
result.current.handleDraftChange({ dispatcherName: "Blair" });
});
act(() => {
result.current.commit();
});
const options = onSave.mock.calls[0][2] as { onSuccess: () => void };
act(() => {
options.onSuccess();
});
expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage());
expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage());
});
});

View file

@ -41,4 +41,25 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
expect(onOpenSlideOver).not.toHaveBeenCalled();
});
it("forwards patch options so vendor persist can toast on success", () => {
const onPatch = vi.fn();
const onSuccess = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-07-20",
onNavigateToDay: vi.fn(),
onOpenSlideOver: vi.fn(),
onOpenMedia: vi.fn(),
onPatch,
onScheduleHighlight: vi.fn(),
}),
);
result.current.onPatch("wo-1", { vendorId: "9" }, { onSuccess });
expect(onPatch).toHaveBeenCalledWith("wo-1", { vendorId: "9" }, { onSuccess });
});
});

View file

@ -0,0 +1,122 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { workOrderCanceledMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts";
const patchField = vi.hoisted(() => vi.fn());
vi.mock("react-toastify", () => ({
toast: {
warning: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
useWorkOrdersListFilters: () => ({
search: "",
handleSearchChange: vi.fn(),
advOpen: false,
setAdvOpen: vi.fn(),
advApplied: null,
advInitial: EMPTY_ADVANCED_FILTERS,
applyAdvancedFilters: vi.fn(),
clearAdvancedFilters: vi.fn(),
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0,
weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(),
types: new Set(),
setTypes: vi.fn(),
}),
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
useWorkOrdersTableData: () => ({
rows: [],
totalCount: 0,
scopeTotalCount: 0,
isFetching: false,
isLoading: false,
setSelectedId: vi.fn(),
patchRow: vi.fn(),
clearRowPatch: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
useWorkOrderTableMutations: () => ({
patchField,
patchFieldAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
useWorkOrderUsers: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: [] }),
useWorkOrderVendors: () => ({ data: [] }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
const row = { id: 77, woNumber: "WO-77" } as WorkOrderTableRow;
describe("useWorkOrdersListPage cancel toast", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("wires Cancel WO to warning toast only after patch onSuccess", () => {
const { result } = renderHook(() => useWorkOrdersListPage());
act(() => {
result.current.setConfirmCancel(row);
});
act(() => {
result.current.handleConfirmCancel();
});
expect(patchField).toHaveBeenCalledWith(
77,
{ status: "Canceled" },
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
expect(toast.warning).not.toHaveBeenCalled();
const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void };
onSuccess();
expect(toast.warning).toHaveBeenCalledWith(workOrderCanceledMessage("WO-77"));
expect(toast.success).not.toHaveBeenCalled();
});
it("completes a work order without a confirmation toast", () => {
const { result } = renderHook(() => useWorkOrdersListPage());
act(() => {
result.current.setConfirmComplete(row);
});
act(() => {
result.current.handleConfirmComplete();
});
expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" });
expect(toast.success).not.toHaveBeenCalled();
expect(toast.warning).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,60 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
const USERS = [
{ id: "sarah", name: "Sarah Chen" },
{ id: "morgan", name: "Morgan Blake" },
];
function assignedToBlock() {
return screen.getByText("Assigned To").closest("div");
}
describe("WizardStepTypeSchedule Assigned To", () => {
it("keeps Assigned To optional and selectable without a role gate", async () => {
const user = userEvent.setup();
const onPatch = vi.fn();
render(
<WizardStepTypeSchedule
draft={EMPTY_WIZARD_DRAFT}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
const block = assignedToBlock();
expect(block).not.toBeNull();
expect(within(block!).getByText("optional")).toBeInTheDocument();
const trigger = within(block!).getByRole("button", { name: /select dispatcher/i });
expect(trigger).toBeEnabled();
await user.click(trigger);
await user.click(await screen.findByRole("button", { name: "Sarah Chen" }));
expect(onPatch).toHaveBeenCalledWith({
assigneeId: "sarah",
assigneeName: "Sarah Chen",
});
});
it("stays interactive when the dispatcher list is empty", () => {
render(
<WizardStepTypeSchedule
draft={EMPTY_WIZARD_DRAFT}
users={[]}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
const block = assignedToBlock();
expect(block).not.toBeNull();
expect(within(block!).getByRole("button", { name: /select dispatcher/i })).toBeEnabled();
});
});

View file

@ -3,6 +3,22 @@ import { describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("WizardStepVendorTime Appt Time", () => {
it("marks Appt Time as optional", () => {
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByText("Appt Time")).toBeInTheDocument();
expect(screen.getAllByText("optional").length).toBeGreaterThan(0);
});
});
describe("WizardStepVendorTime closability gate", () => {
it("disables Completed when closability requirements are missing", () => {
const onUpdate = vi.fn();

View file

@ -0,0 +1,70 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { notifyWorkOrderSaved } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
SiteCell: () => null,
}));
vi.mock(
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
() => ({
EditableWONumberCell: () => null,
}),
);
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
TypeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
DateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
DueDateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
AssignedToCell: ({ onSave }: { onSave: (id: string, name: string) => void }) => (
<button type="button" onClick={() => onSave("u2", "Admin test")}>
persist-assigned
</button>
),
}));
const row = { id: 7, dispatcherId: "u1" } as WorkOrderTableRow;
describe("WoTableRowCells assigned-to persist", () => {
it("forwards assigned-to save through patch onSuccess instead of onPatchRow", () => {
const onPatch = vi.fn();
const onPatchRow = vi.fn();
const handlers = { onPatch } as unknown as WoTableRowHandlers;
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableRowCells
row={row}
allRows={[]}
users={[]}
sites={[]}
handlers={handlers}
onPatchRow={onPatchRow}
/>
</tr>
</tbody>
</table>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "persist-assigned" }));
expect(onPatchRow).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(
7,
{ dispatcherId: "u2", dispatcherName: "Admin test" },
{ onSuccess: notifyWorkOrderSaved },
);
});
});

View file

@ -98,8 +98,8 @@ describe("WoTableRow completed selective lock", () => {
{ withAuth: false },
);
expect(screen.getByTitle("Add color flag")).toBeEnabled();
fireEvent.click(screen.getByTitle("Add color flag"));
expect(screen.getByRole("button", { name: "Add flag" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
expect(screen.getByText("Clear flag")).toBeInTheDocument();
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
expect(screen.getByTitle("View details")).toBeInTheDocument();

View file

@ -0,0 +1,82 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { notifyVendorAssigned } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
PMTypeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
ApptTimeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
StatusCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({
CompDocCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({
UpliftCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
DateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
VendorCell: ({
onSave,
}: {
onSave: (patch: { vendorId: string; company: string; tech: string; techPhone: string }) => void;
}) => (
<button
type="button"
onClick={() => onSave({ vendorId: "9", company: "Gateway", tech: "Pat", techPhone: "555" })}
>
persist-vendor
</button>
),
}));
const row = { id: 7, upliftSummary: { hasUplift: false, pendingCount: 0 } } as WorkOrderTableRow;
describe("WoTableRowServiceCells vendor persist", () => {
it("forwards vendor save through patch onSuccess instead of onPatchRow", () => {
const onPatch = vi.fn();
const onPatchRow = vi.fn();
const handlers = { onPatch } as unknown as WoTableRowHandlers;
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableRowServiceCells
row={row}
vendors={[]}
pmTypes={[]}
handlers={handlers}
onPatchRow={onPatchRow}
/>
</tr>
</tbody>
</table>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "persist-vendor" }));
expect(onPatchRow).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(
7,
toVendorTablePatch({
vendorId: "9",
company: "Gateway",
tech: "Pat",
techPhone: "555",
}),
{ onSuccess: notifyVendorAssigned },
);
});
});

View file

@ -118,6 +118,60 @@ describe("vendorCompanyRosterApi", () => {
);
});
it("throws a duplicate conflict for the stable create response", async () => {
apiPost.mockRejectedValueOnce(
httpError(409, {
code: "duplicate_vendor_company_name",
message: "Another vendor company already uses that name.",
}),
);
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy(
(error: unknown) => {
if (!isVendorRosterConflictError(error)) return false;
return (
error.conflict.kind === "duplicate" &&
error.conflict.message === "Another vendor company already uses that name."
);
},
);
});
it("classifies a duplicate 409 from parsed error.data when the response body is consumed", async () => {
const conflictError = new Error("HTTPError") as Error & {
__kyHTTPError: true;
data: unknown;
response: { status: number; json: () => Promise<unknown>; text: () => Promise<string> };
};
conflictError.__kyHTTPError = true;
conflictError.data = {
code: "duplicate_vendor_company_name",
message: "Another vendor company already uses that name.",
};
const consumedBody = () => {
const rejection = Promise.reject(new TypeError("Body has already been consumed"));
rejection.catch(() => {});
return rejection;
};
conflictError.response = {
status: 409,
json: () => consumedBody(),
text: () => consumedBody(),
};
apiPost.mockRejectedValueOnce(conflictError);
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy(
(error: unknown) => {
if (!isVendorRosterConflictError(error)) return false;
return (
error.conflict.kind === "duplicate" &&
error.conflict.message === "Another vendor company already uses that name."
);
},
);
});
it("rethrows non-conflict errors untouched", async () => {
const generic = new Error("boom");
apiPost.mockRejectedValueOnce(generic);

View file

@ -113,6 +113,20 @@ describe("vendor roster mapper", () => {
expect(conflict.message).toBe("stale rowversion");
});
it("classifies the stable duplicate-company response", () => {
const conflict = mapRosterConflict(
{
code: "duplicate_vendor_company_name",
message: "Another vendor company already uses that name.",
},
"fallback",
);
expect(conflict.kind).toBe("duplicate");
expect(conflict.message).toBe("Another vendor company already uses that name.");
expect(conflict.blockedWorkOrders).toEqual([]);
});
it("preserves an explicit Phone/Email/Text preferred contact on read", () => {
expect(
mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact,

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl,
@ -30,12 +31,29 @@ describe("vendorCompanyRosterSchema", () => {
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.message === "Provide a company phone or email (at least one required)",
(issue) => issue.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
),
).toBe(true);
}
});
it("reports the phone-or-email rule at the companyContact path, not on companyPhone", () => {
const result = vendorCompanyRosterSchema.safeParse({
...emptyVendorCompanyRosterForm,
name: "Solo Co",
});
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues.find(
(candidate) => candidate.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
);
expect(issue?.path).toEqual(["companyContact"]);
expect(result.error.issues.some((candidate) => candidate.path.includes("companyPhone"))).toBe(
false,
);
}
});
it("accepts a company with zero technicians when phone is present", () => {
const result = vendorCompanyRosterSchema.safeParse({
...emptyVendorCompanyRosterForm,
@ -43,6 +61,7 @@ describe("vendorCompanyRosterSchema", () => {
companyPhone: "(314) 555-0100",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
});
it("accepts a company with email only and multiple technicians", () => {

View file

@ -200,6 +200,30 @@ describe("workOrdersApi.fetchDispatchers", () => {
});
});
const GET_USER_SERIALIZED_DIRECTORY = {
data: [
{
Id: "s1",
Name: "Alex Smith",
},
],
};
describe("workOrdersApi.fetchUsers", () => {
beforeEach(() => {
apiGet.mockReset();
});
it("maps the serialized GET User Name as the full display name", async () => {
apiGet.mockResolvedValue(GET_USER_SERIALIZED_DIRECTORY);
const users = await workOrdersApi.fetchUsers();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.user.list);
expect(users).toEqual([{ id: "s1", name: "Alex Smith" }]);
});
});
describe("workOrdersApi.getBoard", () => {
beforeEach(() => {
apiGet.mockReset();

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { flagColorName, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
describe("flag color accessible names", () => {
it("resolves palette hex values to names", () => {
expect(flagColorName("#EF4444")).toBe("Red");
expect(flagColorName("#ef4444")).toBe("Red");
expect(flagColorName("#3B82F6")).toBe("Blue");
});
it("returns null for empty or unknown values", () => {
expect(flagColorName(null)).toBeNull();
expect(flagColorName(undefined)).toBeNull();
expect(flagColorName("")).toBeNull();
expect(flagColorName("#000000")).toBeNull();
});
it("builds trigger names that are not color-only", () => {
expect(flagTriggerAccessibleName(null)).toBe("Add flag");
expect(flagTriggerAccessibleName("#EF4444")).toBe("Red flag");
expect(flagTriggerAccessibleName("#000000")).toBe("Flagged");
});
});

View file

@ -194,6 +194,22 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("does not require Appt Time for wizard step 3 or create", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
apptStart: "",
apptEnd: "",
};
expect(isWizardStepValid(3, draft)).toBe(true);
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
});
it("allows provisional SH-prefixed woNumber", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,

View file

@ -299,6 +299,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined();
});
it("omits apptTime when start and end are empty even with a vendor", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
vendorId: "5",
scheduledOn: "2026-06-25",
apptStart: "",
apptEnd: "",
});
expect(payload.vendorId).toBe(5);
expect(payload.apptDate).toBe("2026-06-25");
expect(payload.apptTime).toBeUndefined();
});
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -70,6 +70,9 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
{ field: "severity", value: "" },
]);
expect(expandTablePatchToBoardFieldOps({ apptTime: "" })).toEqual([
{ field: "apptTime", value: "" },
]);
});
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
@ -205,6 +208,25 @@ describe("buildBoardPatchPayload", () => {
});
});
it("omits non-positive primaryDispatchId on dispatch fields", () => {
const payload = buildBoardPatchPayload(
{ field: "vendorId", value: "7" },
{
rowVersion: "wo-v1",
dispatchRowVersion: "disp-v1",
primaryDispatchId: 0,
},
);
expect(payload).toEqual({
field: "vendorId",
value: "7",
workOrderVersion: "wo-v1",
dispatchVersion: "disp-v1",
});
expect(payload).not.toHaveProperty("primaryDispatchId");
});
it("omits dispatch version for WO-only fields", () => {
const payload = buildBoardPatchPayload(
{ field: "siteCode", value: "BK5" },

View file

@ -0,0 +1,97 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import {
applyAssignedToTableSave,
workOrderSavedMessage,
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
const patchBoardField = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
},
}));
vi.mock("react-toastify", () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
const ROW = {
id: 7,
dispatcherId: "u1",
dispatcherName: "Admin",
rowVersion: "v1",
} as WorkOrderTableRow;
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe("useWorkOrderTableMutations assigned-to toast", () => {
beforeEach(() => {
patchBoardField.mockReset();
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
it("toasts assigned-to save only after the board PATCH succeeds", async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
patchBoardField.mockImplementation(async () => {
await gate;
return { ...ROW, dispatcherId: "u2", dispatcherName: "Admin test", rowVersion: "v2" };
});
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: () => undefined,
clearPatch: () => undefined,
getRow: () => ROW,
}),
{ wrapper: makeWrapper() },
);
act(() => {
applyAssignedToTableSave(result.current.patchField, 7, "u2", "Admin test");
});
expect(toast.success).not.toHaveBeenCalled();
release();
await waitFor(() => expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()));
});
it("does not toast assigned-to save when the board PATCH fails", async () => {
patchBoardField.mockRejectedValue(new Error("Failed to update work order"));
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: () => undefined,
clearPatch: () => undefined,
getRow: () => ROW,
}),
{ wrapper: makeWrapper() },
);
act(() => {
applyAssignedToTableSave(result.current.patchField, 7, "u2", "Admin test");
});
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(toast.success).not.toHaveBeenCalled();
});
});

View file

@ -19,7 +19,9 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
},
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("react-toastify", () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import {
@ -437,6 +439,7 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => {
);
const payload = patchBoardField.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload).not.toHaveProperty("primaryDispatchStatus");
expect(payload.primaryDispatchId).toBe(10);
expect(returnedRow.primaryDispatchId).not.toBe(10);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company");
});
@ -482,4 +485,160 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => {
);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company");
});
it("omits non-positive primaryDispatchId from the vendor PATCH payload", async () => {
rowState = { ...rowState, primaryDispatchId: 0 };
const returnedRow = mapWorkOrderTableRow({
id: 42,
VendorId: 9,
VendorName: "New Co",
PrimaryDispatchStatus: "Pending",
PrimaryDispatchId: 11,
});
patchBoardField.mockResolvedValue(returnedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(
42,
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "",
techPhone: "",
}),
);
});
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
const payload = patchBoardField.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload).toMatchObject({
field: "vendorId",
value: "9",
dispatchVersion: "disp-v1",
});
expect(payload).not.toHaveProperty("primaryDispatchId");
});
});
describe("useWorkOrderTableMutations patchField success callbacks", () => {
beforeEach(() => {
getById.mockReset();
update.mockReset();
patchBoardField.mockReset();
});
it("invokes each onSuccess when two work orders overlap", async () => {
const rows = new Map<string, WorkOrderTableRow>([
["1", { ...BASE_ROW, id: 1, rowVersion: "v1" }],
["2", { ...BASE_ROW, id: 2, rowVersion: "v2" }],
]);
let releaseFirst: () => void = () => undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
patchBoardField.mockImplementation(async (id: unknown) => {
if (String(id) === "1") await firstGate;
const current = rows.get(String(id));
if (!current) throw new Error("missing row");
const next = { ...current, rowVersion: `${current.rowVersion}-n` };
rows.set(String(id), next);
return next;
});
const onSuccessFirst = vi.fn();
const onSuccessSecond = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: () => undefined,
clearPatch: () => undefined,
getRow: (id) => rows.get(String(id)),
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(1, { dispatcherId: "d2" }, { onSuccess: onSuccessFirst });
result.current.patchField(2, { dispatcherId: "d3" }, { onSuccess: onSuccessSecond });
});
await waitFor(() => expect(onSuccessSecond).toHaveBeenCalledTimes(1));
expect(onSuccessFirst).not.toHaveBeenCalled();
releaseFirst();
await waitFor(() => expect(onSuccessFirst).toHaveBeenCalledTimes(1));
});
});
describe("useWorkOrderTableMutations apptTime clear", () => {
it("patches empty apptTime and keeps the appointment date on refetch", async () => {
const datedRow: WorkOrderTableRow = {
...BASE_ROW,
scheduledOn: "2026-06-25",
apptStart: "09:00",
apptEnd: "11:00",
apptTime: "09:00 – 11:00",
vendorId: "7",
primaryDispatchId: 10,
dispatchRowVersion: "disp-v1",
};
const clearedRow: WorkOrderTableRow = {
...datedRow,
apptStart: "",
apptEnd: "",
apptTime: "",
rowVersion: "wo-v2",
};
patchBoardField.mockReset();
patchBoardField.mockResolvedValue(clearedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => datedRow,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(42, { apptTime: "" });
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({
apptTime: "",
scheduledOn: "2026-06-25",
}),
),
);
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({
field: "apptTime",
value: "",
workOrderVersion: "AAAAAAA=",
dispatchVersion: "disp-v1",
primaryDispatchId: 10,
}),
);
});
});

View file

@ -90,6 +90,28 @@ describe("formatBoardAuditAction", () => {
).toBe('Changed extra services to "Other", "Installation", "Preventive Maintenance"');
});
it("formats AssignmentChanged as AAP Assigned To copy", () => {
expect(
formatBoardAuditAction({
action: "AssignmentChanged",
fieldName: "AssignTo",
oldValue: "Unassigned",
newValue: "Morgan Blake",
}),
).toBe('Changed Assigned To to "Morgan Blake"');
});
it("formats empty AssignTo as an em dash", () => {
expect(
formatBoardAuditAction({
action: "AssignmentChanged",
fieldName: "AssignedTo",
oldValue: "Morgan Blake",
newValue: "",
}),
).toBe('Changed Assigned To to "—"');
});
it("falls back to Updated work order when empty", () => {
expect(
formatBoardAuditAction({

View file

@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest";
import {
draftHasScheduleDate,
getInlineCreateMissing,
INLINE_CREATE_REQ_LABELS,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
describe("getInlineCreateMissing", () => {
it("does not require Appt Time for inline create", () => {
expect(
getInlineCreateMissing({
site: "BK5",
locationId: "12",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
expect(Object.keys(INLINE_CREATE_REQ_LABELS)).not.toContain("apptTime");
});
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
expect(
getInlineCreateMissing({

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users";
describe("mergeWorkOrderUsers", () => {
it("keeps dispatcher lookup entries and adds directory-only actors", () => {
const dispatchers = [{ id: "d1", name: "Alex Dispatcher" }];
const directory = [
{ id: "d1", name: "Alex Dispatcher (stale)" },
{ id: "s1", name: "Pat Scheduler" },
];
expect(mergeWorkOrderUsers(dispatchers, directory)).toEqual([
{ id: "d1", name: "Alex Dispatcher" },
{ id: "s1", name: "Pat Scheduler" },
]);
});
it("skips empty ids", () => {
expect(mergeWorkOrderUsers([{ id: "", name: "Bad" }], [{ id: "s1", name: "Pat" }])).toEqual([
{ id: "s1", name: "Pat" },
]);
});
});

View file

@ -3,10 +3,21 @@ import {
fmtDateRange,
fmtShortDate,
pad2,
parseApptTime,
rangeDayCount,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("parseApptTime", () => {
it("parses a 24-hour start-only value", () => {
expect(parseApptTime("09:00")).toEqual({ start: "09:00", end: "" });
});
it("leaves free-text labels unparsed", () => {
expect(parseApptTime("AM before 2pm")).toEqual({ start: "", end: "" });
});
});
describe("fmtShortDate", () => {
it("formats ISO dates as Mon D", () => {
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");

Some files were not shown because too many files have changed in this diff Show more