Merge branch 'dev' into feat/sh-167-wo-board-carry-over
37
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
|
@ -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."
|
||||
2
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 185 KiB |
135
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export function formatUpliftMoney(value: number): string {
|
||||
return `$${value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
100
src/app/(protected)/uplifts/_components/reject-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/app/(protected)/uplifts/_components/revoke-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
89
src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx
vendored
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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)" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
20
src/components/ui/dialog-cancel-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
2
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
33
src/domain/work-orders/utils/appt-time-selection.ts
Normal 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("");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
18
src/domain/work-orders/utils/merge-work-order-users.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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: "" };
|
||||
}
|
||||
|
||||
|
|
|
|||
91
src/domain/work-orders/utils/work-order-feedback-toasts.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
149
src/test/app/(protected)/uplifts/reject-revoke-dialogs.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
22
src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
140
src/test/app/(protected)/workorders/appt-time-cell.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("", "");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
59
src/test/app/(protected)/workorders/flag-cell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
23
src/test/domain/work-orders/flag-colors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,6 +90,28 @@ describe("formatBoardAuditAction", () => {
|
|||
).toBe('Changed extra services to "Other", "Installation", "Preventive Maintenance"');
|
||||
});
|
||||
|
||||
it("formats AssignmentChanged as AAP Assigned To copy", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "AssignmentChanged",
|
||||
fieldName: "AssignTo",
|
||||
oldValue: "Unassigned",
|
||||
newValue: "Morgan Blake",
|
||||
}),
|
||||
).toBe('Changed Assigned To to "Morgan Blake"');
|
||||
});
|
||||
|
||||
it("formats empty AssignTo as an em dash", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "AssignmentChanged",
|
||||
fieldName: "AssignedTo",
|
||||
oldValue: "Morgan Blake",
|
||||
newValue: "",
|
||||
}),
|
||||
).toBe('Changed Assigned To to "—"');
|
||||
});
|
||||
|
||||
it("falls back to Updated work order when empty", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
|
|
|
|||
|
|
@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
draftHasScheduleDate,
|
||||
getInlineCreateMissing,
|
||||
INLINE_CREATE_REQ_LABELS,
|
||||
inlineCreateSaveHint,
|
||||
inlineCreateSiteError,
|
||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||
|
||||
describe("getInlineCreateMissing", () => {
|
||||
it("does not require Appt Time for inline create", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "BK5",
|
||||
locationId: "12",
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(Object.keys(INLINE_CREATE_REQ_LABELS)).not.toContain("apptTime");
|
||||
});
|
||||
|
||||
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users";
|
||||
|
||||
describe("mergeWorkOrderUsers", () => {
|
||||
it("keeps dispatcher lookup entries and adds directory-only actors", () => {
|
||||
const dispatchers = [{ id: "d1", name: "Alex Dispatcher" }];
|
||||
const directory = [
|
||||
{ id: "d1", name: "Alex Dispatcher (stale)" },
|
||||
{ id: "s1", name: "Pat Scheduler" },
|
||||
];
|
||||
|
||||
expect(mergeWorkOrderUsers(dispatchers, directory)).toEqual([
|
||||
{ id: "d1", name: "Alex Dispatcher" },
|
||||
{ id: "s1", name: "Pat Scheduler" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips empty ids", () => {
|
||||
expect(mergeWorkOrderUsers([{ id: "", name: "Bad" }], [{ id: "s1", name: "Pat" }])).toEqual([
|
||||
{ id: "s1", name: "Pat" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,10 +3,21 @@ import {
|
|||
fmtDateRange,
|
||||
fmtShortDate,
|
||||
pad2,
|
||||
parseApptTime,
|
||||
rangeDayCount,
|
||||
todayIso,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
describe("parseApptTime", () => {
|
||||
it("parses a 24-hour start-only value", () => {
|
||||
expect(parseApptTime("09:00")).toEqual({ start: "09:00", end: "" });
|
||||
});
|
||||
|
||||
it("leaves free-text labels unparsed", () => {
|
||||
expect(parseApptTime("AM before 2pm")).toEqual({ start: "", end: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtShortDate", () => {
|
||||
it("formats ISO dates as Mon D", () => {
|
||||
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");
|
||||
|
|
|
|||