Compare commits

...

49 commits

Author SHA1 Message Date
Arthur Bassi
bc1e7a1fa2 fix(work-orders): keep carry-over rows in day reorder and one-char search 2026-09-02 09:47:34 -03:00
Arthur Bassi
08e00c213d
Merge branch 'dev' into feat/sh-167-wo-board-carry-over 2026-09-02 09:32:58 -03:00
Arthur Bassi
c70587941c
Merge pull request #162 from Sea-Haven-Industries/fix/sh-297-assigned-to-save-toast
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(work-orders): toast after assigned-to save on the board
2026-09-02 09:32:21 -03:00
Arthur Bassi
7e1dcb7a36 fix(work-orders): toast after assigned-to save on the board
Show the save toast only after the board PATCH succeeds.
2026-09-01 10:20:38 -03:00
Arthur Bassi
0ee2879234
Merge pull request #161 from Sea-Haven-Industries/fix/sh-296-vendor-invalid-dispatch
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(work-orders): omit non-positive primaryDispatchId on board patches
2026-09-01 09:50:44 -03:00
Arthur Bassi
85ea33b0ad
Merge branch 'dev' into fix/sh-296-vendor-invalid-dispatch 2026-08-31 16:36:50 -03:00
Arthur Bassi
8d26a07fa4
Merge pull request #156 from Sea-Haven-Industries/feature/sh-166-accessible-row-flags
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): expose accessible names on board row flags [SH-166]
2026-08-31 16:29:23 -03:00
Arthur Bassi
a3b889c15e
Merge branch 'dev' into feature/sh-166-accessible-row-flags 2026-08-31 16:04:27 -03:00
Arthur Bassi
88c3d08afa
Merge pull request #153 from Sea-Haven-Industries/feat/sh-131-appt-time
feat(work-orders): make appointment time optional in create flows [SH-131]
2026-08-31 15:51:12 -03:00
Arthur Bassi
72417bbb77 fix(work-orders): omit non-positive primaryDispatchId on board patches
A GET echo of 0 on primaryDispatchId made vendor PATCH fail InvalidDispatch.
2026-08-31 15:48:31 -03:00
Arthur Bassi
a90acad331 test(work-orders): refresh New WO Linux visual baseline after merge
CI failed on 73 pixels in the board behind the wizard. Use the Linux actual so visual regression matches the merged Assigned To board.
2026-08-31 15:21:24 -03:00
Arthur Bassi
55a6ff03b1 merge(dev): keep Linux CI New WO visual baseline
The only conflict was wo-new.png (Type and schedule). Appt Time is on a later step, so origin/dev's Assigned To screenshot is the correct combined UI.
2026-08-31 15:11:43 -03:00
Arthur Bassi
d255fb6b91
Merge pull request #152 from Sea-Haven-Industries/feature/sh-127-assigned-to-ownership
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): keep Assigned To open and name audit actors [SH-127]
2026-08-31 13:51:03 -03:00
Arthur Bassi
065ebbd403 fix(work-orders): expose flag picker as named dialog 2026-08-31 11:46:04 -03:00
Arthur Bassi
07c03e99b9 test(work-orders): assert empty apptTime board PATCH and refetch
EOF
2026-08-31 10:54:28 -03:00
Arthur Bassi
8031357dfa fix(work-orders): assert GET User full display name 2026-08-31 10:22:27 -03:00
Arthur Bassi
8d44b1868c fix(work-orders): expose disabled flag name via img role 2026-08-31 09:49:09 -03:00
Arthur Bassi
bae6d15043
Merge branch 'dev' into feature/sh-166-accessible-row-flags 2026-08-31 09:43:03 -03:00
Arthur Bassi
8b745608dc Merge branch 'feat/sh-131-appt-time' of https://github.com/Sea-Haven-Industries/shoc-frontend-new into feat/sh-131-appt-time 2026-08-31 09:36:35 -03:00
Arthur Bassi
ac9f4ff6e7
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-31 09:36:18 -03:00
Arthur Bassi
15459e8d40 fix(work-orders): let picker toggle clear appointment start and end
EOF
2026-08-31 09:35:49 -03:00
Arthur Bassi
e9e17dadec fix(work-orders): compose full user names from User list 2026-08-31 09:23:10 -03:00
Arthur Bassi
223a734b11
Merge branch 'dev' into feature/sh-127-assigned-to-ownership 2026-08-31 09:17:03 -03:00
Alexandre Brandizzi
d6755a63b6
fix(vendors): clarify blank technician draft state (#157)
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
* fix(vendors): clarify blank technician draft state

* test(vendors): update technician section snapshots

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-28 15:00:02 -04:00
Arthur Bassi
b619b8ae13 feat(work-orders): expose accessible names on board row flags
Give keyboard and screen-reader users color-independent labels
when setting, changing, or clearing a work-order flag.
2026-08-28 15:06:59 -03:00
Arthur Bassi
f2cd68c57c
Merge branch 'dev' into feature/sh-127-assigned-to-ownership 2026-08-28 12:31:06 -03:00
Arthur Bassi
740048508c
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-28 12:31:03 -03:00
Alexandre Brandizzi
5e38c93b1e
fix(vendors): truncate long textual table values (#154)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
* fix(vendors): truncate long locations in table

* fix(vendors): truncate textual table cells

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-28 11:21:15 -04:00
Alexandre Brandizzi
4df6e76192
ci: add protected staging frontend deployment lane (#151)
* ci: add protected staging deployment lane

* fix: constrain staging publisher permissions

* fix: handle first-push governance baseline

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-28 10:59:55 -04:00
Arthur Bassi
650543abb3 test(e2e): refresh wo-mobile-navigation visual baseline 2026-08-28 11:55:29 -03:00
Arthur Bassi
fb54414ade fix(work-orders): parse start-only appointment times and refresh visual baselines 2026-08-28 11:46:54 -03:00
Arthur Bassi
8bb126b1a3 feat(work-orders): make appointment time optional in create flows
Allow empty Appt Time in wizard, table, and slide-over without blocking
create or complete. Omit appointment fields without vendorId so create
does not hit DispatchRequired.
2026-08-28 11:05:43 -03:00
Arthur Bassi
38571d3386 test(work-orders): use Linux CI baseline for New WO screenshot 2026-08-28 10:08:23 -03:00
Arthur Bassi
7954153e9c test(work-orders): refresh New WO visual baseline for Assigned To copy 2026-08-28 09:45:38 -03:00
Arthur Bassi
0d883f625c chore: merge origin/dev into feature/sh-127-assigned-to-ownership 2026-08-28 09:27:18 -03:00
Arthur Bassi
a299b8980c feat(work-orders): keep Assigned To open and name audit actors [SH-127]
Keep assignment ungated. Match AAP unassigned copy and name audit actors.
2026-08-28 09:19:43 -03:00
Alexandre Brandizzi
40ce66fbb3
fix(vendors): keep Company label visible after scroll (#150)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
* fix(vendors): keep Company label visible after scroll

* test(vendors): align Add Vendor visual baseline with CI

* test(vendors): prove full label visibility after scroll

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-27 20:52:42 +00:00
Alexandre Brandizzi
7fdef2a46a
fix: classify duplicate vendor conflicts from parsed body (#149)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-27 14:33:17 -04:00
Alexandre Brandizzi
7ec522e8ea
fix: complete SH-283 vendor QA paths (#148)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-27 15:03:36 -03:00
Alexandre Brandizzi
3b1fe016fe
fix(vendors): clarify required company fields (#146)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
* fix(vendors): clarify required company fields

* test(vendors): refresh company label snapshots

* refactor(vendors): isolate contact fields

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-08-27 13:01:53 -04:00
Arthur Bassi
0df17d49f4
Merge pull request #147 from Sea-Haven-Industries/feature/sh-120-uplift-reject-revoke-dialogs
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(uplifts): align Reject/Revoke Cancel and required asterisk [SH-120]
2026-08-27 12:12:43 -03:00
Arthur Bassi
305b926128 fix(uplifts): keep two-decimal NTE and expose required on reason fields 2026-08-27 11:59:25 -03:00
Arthur Bassi
ba2e3e37c7 feat(uplifts): align Reject/Revoke Cancel and required asterisk [SH-120] 2026-08-27 11:35:54 -03:00
Alexandre Brandizzi
4a3d04ed01
Merge pull request #145 from Sea-Haven-Industries/feature/sh-119-toast-feedback
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): add consistent save/delete toast feedback
2026-08-26 19:57:18 -03:00
Codex Review Integration
2b17de2212 fix(work-orders): confirm cleared vendor saves 2026-08-26 19:47:57 -03:00
Arthur Bassi
8d755bcb11 fix(work-orders): address PR review on toast feedback
Scope vendor and save toasts to successful assigned-vendor and slide-over saves.
Bind patch success callbacks per operation instead of shared mutate observers.
2026-08-26 19:30:29 -03:00
Arthur Bassi
97b5e287aa
Merge branch 'dev' into feature/sh-119-toast-feedback 2026-08-26 19:09:45 -03:00
Adam Moussa
481fa29f42
ci: pin github actions to immutable shas (#144)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
2026-08-26 19:04:46 -03:00
Arthur Bassi
5a0e907067 feat(work-orders): add consistent save/delete toast feedback
Cancel WO and vendor assignment toast after a successful board PATCH.
2026-08-26 18:42:56 -03:00
107 changed files with 3594 additions and 378 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

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import {
@ -62,8 +63,11 @@ export function useWorkOrdersTableLocalState({
const displayRows = useMemo(() => {
const apiIds = new Set(apiRows.map((r) => String(r.id)));
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
}, [apiRows, localRows, rowOrderByDay]);
const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) =>
applyCarryOverDisplayDayFields(row, weekMonday),
);
return applyRowOrderByDay(merged, rowOrderByDay);
}, [apiRows, localRows, rowOrderByDay, weekMonday]);
const clearRowPatch = useCallback((id: string | number) => {
const key = String(id);

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

@ -1,5 +1,6 @@
import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils";
import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
@ -92,3 +93,17 @@ export function rowMatchesCarryOverDay(
}
return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso;
}
/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */
export function applyCarryOverDisplayDayFields<
T extends CarryOverRow & { dayGroup: string; dayLabel: string },
>(row: T, weekMonday: string): T {
if (!isCarryOverDisplayRow(row, weekMonday)) {
return row;
}
const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday);
if (!displayDay) {
return row;
}
return { ...row, ...deriveDayFields(displayDay) };
}

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

@ -7,6 +7,7 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
@ -96,7 +97,7 @@ export function filterByBarAndSearch(
if (row.scheduledOn) {
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
return false;
return isCarryOverDisplayRow(row, bar.weekMonday);
}
} else if (row.targetWeek) {
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {

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

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
applyCarryOverDisplayDayFields,
formatCarriedOverHover,
isCarryOverDisplayRow,
mapCarryOverDisplayDay,
@ -60,4 +61,34 @@ describe("carry-over-display", () => {
expect(mapCarryOverDisplayDay("2026-05-16", "2026-05-18")).toBe("2026-05-18");
expect(mapCarryOverDisplayDay("2026-05-17", "2026-05-18")).toBe("2026-05-18");
});
it("remaps dayGroup to the viewed weekday without changing scheduledOn", () => {
const row = {
scheduledOn: "2026-05-13",
status: "Scheduled",
dayGroup: "05/13",
dayLabel: "WED · 05/13",
};
const next = applyCarryOverDisplayDayFields(row, "2026-05-18");
expect(next.scheduledOn).toBe("2026-05-13");
expect(next.dayGroup).toBe("05/20");
expect(next.dayLabel).toBe("WED · 05/20");
});
it("leaves in-week and terminal rows' dayGroup unchanged", () => {
const inWeek = {
scheduledOn: "2026-05-20",
status: "Scheduled",
dayGroup: "05/20",
dayLabel: "WED · 05/20",
};
const completed = {
scheduledOn: "2026-05-13",
status: "Completed",
dayGroup: "05/13",
dayLabel: "WED · 05/13",
};
expect(applyCarryOverDisplayDayFields(inWeek, "2026-05-18")).toEqual(inWeek);
expect(applyCarryOverDisplayDayFields(completed, "2026-05-18")).toEqual(completed);
});
});

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