chore(work-orders): resolve SH-388 uplift conflicts onto current main

Keep the slide-over uplift tab and the evidence upload while taking the
current board, dashboard, and vendor changes from main.
This commit is contained in:
Arthur Bassi 2026-09-24 15:17:34 -03:00
commit 3c5941b895
476 changed files with 27390 additions and 5541 deletions

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

21
.github/renovate.json vendored
View file

@ -1,6 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["npm", "custom.regex", "terraform"],
"enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"],
"schedule": ["before 6am every weekday"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
@ -14,6 +15,17 @@
"datasourceTemplate": "npm",
"depNameTemplate": "@playwright/test",
"versioningTemplate": "npm"
},
{
"customType": "regex",
"description": [
"actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard"
],
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
"matchStrings": ["ACTIONLINT_VERSION: \"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
"datasourceTemplate": "github-releases",
"depNameTemplate": "rhysd/actionlint",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
"packageRules": [
@ -36,6 +48,13 @@
"dependencyDashboardApproval": true,
"groupName": "playwright"
},
{
"description": [
"Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version"
],
"matchPackageNames": ["rhysd/actionlint"],
"dependencyDashboardApproval": true
},
{
"description": ["Keep MUI packages together"],
"matchPackageNames": ["@mui/**"],

View file

@ -2,37 +2,122 @@ name: Frontend checks
on:
pull_request:
branches: [main, dev, staging]
branches: [main, dev]
# The merge queue builds main plus the queued pull requests on a temporary
# branch and only counts checks that ran on the merge_group event.
merge_group:
push:
branches: [main, dev, staging]
branches: [main]
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
static:
name: static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run build
unit:
name: unit
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
visual:
name: Visual regression
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
browser-smoke:
name: browser-smoke
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e
- name: Upload smoke artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-smoke-artifacts
path: |
test-results
playwright-report
if-no-files-found: ignore
retention-days: 14
governance:
# Repo-owned guarantee that every frontend quality gate runs from this
# repository, independent of (and in addition to) the reusable workflow.
# `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
# and GitHub workflow shell). If the reusable workflow is later confirmed
# to run every gate, this job can be slimmed to `npm run governance`.
# Repo-owned gates: godfile ratchet, changed-file maintainability,
# Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront
# verify, GitHub workflow shell, isolation classifier tests, and live G13
# (pull_request, merge_group per queued PR, and local). Format, lint, build,
# and unit tests run
# in the parallel jobs above, not here.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
# merge group -> the group's own base (github.event.merge_group.base_sha)
# push-> the previous commit on the branch (github.event.before)
# manual -> dev, for exact-head recovery runs
# manual -> main, for exact-head recovery runs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -45,14 +130,17 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
base="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then
base="${MERGE_GROUP_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
base="origin/main"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- name: Set up Terraform
@ -78,29 +166,29 @@ jobs:
tar -xzf actionlint.tar.gz actionlint
sudo mv actionlint /usr/local/bin/actionlint
- run: npm ci
- run: npm run verify
- run: npm run governance
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
visual-regression:
name: Visual regression
ci-complete:
name: ci-complete
if: always()
needs: [static, build, unit, visual, browser-smoke, governance]
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
- name: All required jobs passed
shell: bash
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
failed=0
for result in ${RESULTS}; do
if [[ "${result}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo "Required jobs did not all succeed: ${RESULTS}"
exit 1
fi

View file

@ -1,148 +0,0 @@
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
VITE_SENTRY_ENVIRONMENT: staging
VITE_APP_COMMIT_SHA: ${{ github.sha }}
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 and Sentry environment
# label (process env overrides the dev values 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: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- 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."

316
.github/workflows/deploy-web.yaml vendored Normal file
View file

@ -0,0 +1,316 @@
name: Deploy Web
# SPA CD. GitHub Actions builds dist/ and syncs it to the S3 origin bucket
# root, then invalidates CloudFront. Terraform owns the bucket and the
# distribution and never touches content.
#
# push to main -> dev, at github.sha
# release: published -> staging, at vX.Y.Z-staging (must be on main)
# workflow_dispatch -> chosen environment at a chosen ref
#
# Releases are cut by a human with
# `gh release create vX.Y.Z-staging --target main --generate-notes`.
# A workflow cannot do it: releases created with GITHUB_TOKEN do not fire
# `release: published`. Core vX.Y.Z waits until a prod distribution exists.
#
# Bucket and distribution come from SSM after assuming the Environment's
# DEPLOY_ROLE_ARN. Nothing here creates an HCP run. Quality gates live in CI.
#
# The SPA checkout is the resolved content ref. Deploy scripts are copied
# from github.workflow_sha so workflow_dispatch of an older SHA still runs
# the current upload/verify path.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "**/*.md"
- ".github/workflows/ci.yaml"
- ".github/workflows/deploy-web.yaml"
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: "Target Environment"
required: true
type: choice
options: [dev, staging]
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
target:
name: Resolve target
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
environment: ${{ steps.resolve.outputs.environment }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
GITHUB_REF_NAME_IN: ${{ github.ref }}
GITHUB_SHA_IN: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then
echo "release tag ${RELEASE_TAG} is not vX.Y.Z-staging; refusing until a prod distribution exists." >&2
exit 1
fi
environment=staging
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy:
name: Deploy SPA to ${{ needs.target.outputs.environment }}
needs: target
runs-on: ubuntu-latest
timeout-minutes: 45
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-web-${{ needs.target.outputs.environment }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.target.outputs.ref }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Checkout workflow deploy scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
sparse-checkout: |
scripts
sparse-checkout-cone-mode: true
path: .workflow-scripts
- name: Install workflow deploy scripts
run: |
set -euo pipefail
test -f .workflow-scripts/scripts/upload-sourcemaps.sh
test -f .workflow-scripts/scripts/verify-cloudfront-release.sh
test -f .workflow-scripts/scripts/summarize-cloudfront-live-state.sh
mkdir -p scripts
cp .workflow-scripts/scripts/upload-sourcemaps.sh scripts/
cp .workflow-scripts/scripts/verify-cloudfront-release.sh scripts/
cp .workflow-scripts/scripts/summarize-cloudfront-live-state.sh scripts/
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build SPA
env:
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ needs.target.outputs.environment }}
VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
# vars.VITE_SENTRY_DSN is unset today. An empty env value would
# override .env.production and disable Sentry (Vite does not let
# .env overwrite an existing variable).
if [ -n "${VITE_SENTRY_DSN:-}" ]; then
export VITE_SENTRY_DSN
else
unset VITE_SENTRY_DSN
fi
case "${TARGET_ENVIRONMENT}" in
dev)
export VITE_API_URL="https://api.dev.seahaven.com/api"
forbidden="api.staging.seahaven.com"
required="api.dev.seahaven.com"
;;
staging)
export VITE_API_URL="https://api.staging.seahaven.com/api"
forbidden="api.dev.seahaven.com"
required="api.staging.seahaven.com"
;;
*)
echo "unsupported environment ${TARGET_ENVIRONMENT}" >&2
exit 1
;;
esac
npm ci
npm run build
test -f dist/index.html
if grep -Rq "${forbidden}" dist/; then
echo "Built assets contain the forbidden URL ${forbidden}." >&2
exit 1
fi
if grep -Rq "localhost:5141" dist/; then
echo "Built assets contain the Vite proxy target localhost:5141." >&2
exit 1
fi
grep -Rq "${required}" dist/
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "VITE_API_URL=${VITE_API_URL}" >> "${GITHUB_ENV}"
echo "dist/index.html sha256=${index_sha}"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
- name: Strip source maps from dist/
run: |
set -euo pipefail
find dist -name '*.map' -delete
if find dist -name '*.map' | grep -q .; then
echo "SPA source maps must not ship in dist/" >&2
exit 1
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: spa-dist-${{ needs.target.outputs.environment }}-${{ steps.commit.outputs.sha }}
path: dist/
if-no-files-found: error
retention-days: 7
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
env:
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
prefix="/shoc-frontend-new/${TARGET_ENVIRONMENT}/deploy"
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
# Refuse to touch the bucket until Terraform has moved every origin
# to the bucket root. The previous CD pointed origins at
# /releases/<label>; syncing and pruning under that layout would
# serve a broken site or delete the live prefix.
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
if [ -n "${origin_paths}" ]; then
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
exit 1
fi
{
echo "bucket=${BUCKET}"
echo "distribution_id=${DIST_ID}"
echo "site_url=https://${DOMAIN}"
} >> "${GITHUB_OUTPUT}"
- name: Sync dist/ to the bucket root
env:
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
run: |
set -euo pipefail
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
- name: Invalidate CloudFront
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
run: |
set -euo pipefail
invalidation_id="$(aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION_ID}" \
--paths "/*" \
--query Invalidation.Id --output text)"
echo "Invalidation ${invalidation_id} created; waiting"
aws cloudfront wait invalidation-completed \
--distribution-id "${DISTRIBUTION_ID}" \
--id "${invalidation_id}"
- name: Verify served release
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
API_URL: ${{ env.VITE_API_URL }}
run: bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -1,299 +0,0 @@
name: Deploy dev content
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
# group, and invalidation. Push-to-dev stays off until
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
#
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
# re-run those gates on pull requests, pushes, or workflow_dispatch.
on:
push:
branches: [dev]
paths-ignore:
- "terraform/**"
workflow_dispatch: {}
permissions:
contents: read
jobs:
deploy-dev:
name: Deploy shoc-frontend-new-dev through Terraform
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
contents: read
id-token: write
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
AWS_REGION: us-east-1
TF_CLOUD_ORGANIZATION: seahaven
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
SITE_BUCKET: seahaven-shoc-frontend-dev
DISTRIBUTION_ID: E2CWLM1AFB964P
SITE_URL: https://dev.seahaven.com
VITE_API_URL: https://api.dev.seahaven.com/api
VITE_APP_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build SPA
run: |
set -euo pipefail
npm ci
npm run build
if grep -Rq "api.staging.seahaven.com" dist/; then
echo "::error::Built assets contain the staging API URL." >&2
exit 1
fi
if grep -Rq "localhost:5141" dist/; then
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
exit 1
fi
grep -Rq "api.dev.seahaven.com" dist/
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Assign immutable release identity
id: release
run: |
set -euo pipefail
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
prefix="releases/${version_label}"
{
echo "version_label=${version_label}"
echo "prefix=${prefix}"
} >> "${GITHUB_OUTPUT}"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
- name: Read previous release pointer
id: pointer
run: |
set -euo pipefail
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
- name: Upload immutable release prefix
run: |
set -euo pipefail
prefix="${{ steps.release.outputs.prefix }}"
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${prefix}/index.html"
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
- name: Capture previous served hash
id: previous-hash
run: |
set -euo pipefail
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub CD
id: discard-vcs
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform release run
id: release-run
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
with:
workspace: shoc-frontend-new-dev
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
- name: Read Terraform release plan counts
id: release-plan
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.release-run.outputs.plan_id }}
- name: Reject non-release resource counts
env:
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform plan
run: |
set -euo pipefail
# Flags must match check-terraform-release-plan.py. Pointer `before`
# and origin-ID-set stability are asserted from the plan JSON.
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.release.outputs.version_label }}" \
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
- name: Discard release run when the guard fails
if: failure() && steps.release-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions
- name: Apply Terraform release run
id: release-apply
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
- name: Treat already-applied release run as success
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.release-run.outputs.run_id }}" \
--apply-outcome "${{ steps.release-apply.outcome }}"
- name: Verify CloudFront release
env:
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
run: bash scripts/verify-cloudfront-release.sh
- name: Restore previous release on failure
if: failure()
id: rollback-prepare
run: |
set -euo pipefail
prev="${{ steps.pointer.outputs.live_current }}"
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
exit 0
fi
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub rollback
id: rollback-discard-vcs
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform rollback run
id: rollback-run
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
with:
workspace: shoc-frontend-new-dev
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
- name: Read Terraform rollback plan counts
id: rollback-plan
if: failure() && steps.rollback-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.rollback-run.outputs.plan_id }}
- name: Reject non-release rollback counts
id: rollback-count-guard
if: failure() && steps.rollback-plan.outcome == 'success'
env:
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform rollback plan
id: rollback-json-guard
if: failure() && steps.rollback-count-guard.outcome == 'success'
run: |
set -euo pipefail
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
- name: Discard rollback run when the guard fails
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions
- name: Apply Terraform rollback run
id: rollback-apply
if: failure() && steps.rollback-json-guard.outcome == 'success'
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
- name: Treat already-applied rollback run as success
id: rollback-apply-result
if: failure() && steps.rollback-apply.outcome != 'skipped'
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
--apply-outcome "${{ steps.rollback-apply.outcome }}"
- name: Verify CloudFront rollback
if: failure() && steps.rollback-apply-result.outcome == 'success'
env:
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
run: |
set -euo pipefail
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export EXPECTED_INDEX_SHA256="${expected_sha}"
bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -1,43 +0,0 @@
name: Terraform isolation
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
# would report required checks as success and could merge a failing SHA.
on:
pull_request:
branches: [main, dev, staging]
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
permissions:
contents: read
jobs:
terraform-isolation:
# Fails a pull request that changes Terraform infrastructure together with
# deployable application code (scripts/check-terraform-isolation.mjs). A
# merge that does both queues an HCP VCS run and a content release at the
# same time, and the two race for the workspace lock. The
# `terraform-isolation-override` label is the reviewed exception. This
# job is unconditional so adding or removing that label always reads the
# current label set; a previous green check does not survive removal.
name: Terraform and application changes are isolated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Check changed files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ seed-data.sql
# python
__pycache__/
*.py[cod]
# local scratch
/tmp/

View file

@ -28,9 +28,11 @@ npm run verify
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
checks (`npm run governance`). **Do not claim a task is done until `npm run
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
`governance` job, so a green local run mirrors CI.
checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
claim a task is done until `npm run verify` is green locally.** CI runs the same
gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)
(`static`, `build`, `unit`, `visual`, `browser-smoke`, `governance`) with
`ci-complete` failing if any of those jobs did not succeed.
## Non-negotiable rules (enforced; do not work around)

View file

@ -8,32 +8,30 @@ npm run verify
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. Governance also runs the repository
gates: Terraform import-plan and release-plan checkers, isolation tests,
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
workflow shell checks. A task is not done until this is green.
gates: Terraform import-plan checker, Terraform formatting and validation, the
HCP run guard, CloudFront verify, workflow shell checks, and G13 (app/Terraform
isolation). A task is not done until this is green.
## Gate matrix
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
## No-false-pass guarantees
@ -51,28 +49,29 @@ workflow shell checks. A task is not done until this is green.
-lockfile=readonly` and `validate` run offline; the plan checker is tested
against synthetic plan JSON. Real import and controlled-update plans from HCP
are migration evidence reviewed by a human before an approved apply
(`terraform/README.md`).
- **The isolation gate re-evaluates on label changes** — the
`terraform-isolation-override` label is the only way to merge a mixed
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
default pull-request types, so adding or removing the label re-checks
the current labels without starting a new Frontend checks run. Removing
the label fails a mixed PR that had previously passed with the override.
(`terraform/README.md`). G13 fails a diff that contains both `terraform/`
and deployable application files (`src/`, `public/`, `pages/`, `config/`,
`index.html`, Vite/tsconfig, or `.env*`). Workflow,
docs, and gate-script changes may travel with either side. Runtime isolation
stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP
when workspace trigger patterns miss.
## Where the gates run
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
Prettier on staged files at commit; commitlint enforces Conventional Commits.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
format/lint/build/tests, **and** a repo-owned `governance` job runs
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow.
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
on pull requests, fails when Terraform infrastructure and application code
change together. Label add/remove re-runs only this workflow.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
repository owns every job. `static` (`format:check` + `lint`), `build`
(`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
(Playwright visual), `browser-smoke` (`npm run test:e2e`), and `governance`
(`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete`
fails unless all of those jobs succeeded and is the required merge-queue
check. Live G13 runs on
`pull_request` (merge-base range), `merge_group` (each queued PR as a
first-parent commit), and locally. It skips `push`. A Terraform-only PR
stacked with an app-only PR still passes; a mixed change set still fails.
Terraform fmt/validate and the related unit tests run inside `governance` on
every event.
## Toolchain pin

119
README.md
View file

@ -1,7 +1,7 @@
# SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
@ -13,29 +13,31 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
Static SPA hosting on AWS, owned by HCP Terraform
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
`dist/` from a private S3 bucket using a current/previous origin group;
`dist/` from a private S3 bucket at the bucket root;
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
proxy at the CDN — the backend allows CORS).
```mermaid
graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -->|OAC bucket root| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions] -->|OIDC upload releases/*| S3
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
GH[GitHub Actions] -->|OIDC s3 sync dist/| S3
TF[HCP Terraform] -->|bucket CloudFront IAM SSM| CF
```
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
Dev and staging hosting live in `terraform/live/dev` and
`terraform/live/staging`. GitHub `.github/workflows/deploy-web.yaml` syncs
content.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
@ -47,13 +49,13 @@ architecture plan for the keep/discard migration matrix).
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to Environment `dev` plus `deploy-web.yaml` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
No Lambdas, queues, or databases — this stack is static hosting only.
@ -61,8 +63,8 @@ No Lambdas, queues, or databases — this stack is static hosting only.
### Secrets
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
No Secrets Manager. Deploy looks up `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`
after assuming `DEPLOY_ROLE_ARN`. AWS access is OIDC only. The one **GitHub Actions
repo secret** is:
| Secret | Purpose |
@ -121,62 +123,49 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
- Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time.
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
a green CI run and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- A PR that changes `terraform/**` may not also change application code (the
`terraform-isolation` CI job); ship Terraform in its own PR.
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
`→ main` (production promotion — no prod environment exists yet).
- Open PRs against `main`. The PR body uses the three-section layout the
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
`ci-complete` check and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
approvals. PRs merge through the merge queue, so a branch does not need to
be updated with `main` before it merges. Merged branches are deleted
automatically.
- A change set cannot mix `terraform/` with deployable application files (G13),
including each queued PR on the merge-group check. Workflow, docs, and
gate-script changes may travel with either side. `deploy-web.yaml` still
ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
prod distribution exists.
## Deployment
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`/`staging`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
the Terraform gates, and the content-CD guards) is guaranteed from this
repository. Conventions and gates are documented under
and PRs to `main`, runs format, lint, build, sharded unit tests, visual
regression, Playwright smoke, and `npm run governance` as parallel jobs, then
`ci-complete`.
Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **Terraform isolation**
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml))
— fails a PR that mixes `terraform/**` with application code, so a Terraform
merge never races a content release for the HCP workspace.
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
— `workflow_dispatch` on `dev`, and push-to-`dev` when
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
`.release/current`, both origin paths, and the invalidation action. Verify
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
a live-state summary.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run HCP Terraform workspace
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
Staging hosting stays on the existing CloudFormation stack until SH-287.
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
content publisher only.
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
- **Infrastructure** — HCP workspaces `shoc-frontend-new-dev` and
`shoc-frontend-new-staging` ([`terraform/README.md`](terraform/README.md)).
## Operations
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy Web**
run in the Actions tab; confirm a deep link (e.g. a work-orders
route) loads directly and API calls succeed.
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
are no CloudWatch application logs — the stack is static hosting; runtime
@ -185,20 +174,18 @@ content publisher only.
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
still serves the previous `index.html` hash. Read the live-state summary
before assuming the site is down.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
on this repo; dispatching the workflow from another branch is rejected by
design.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to Environment
`dev` or `staging` plus `deploy-web.yaml`. A job without `environment:`
cannot assume the role.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`.
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
an HCP Terraform run that a human confirms or discards before the next
content release (see the operational rules in `terraform/README.md`).
- _Non-empty origin path_ — `deploy-web.yaml` refuses to sync until Terraform
has moved every origin to the bucket root.
## Documentation
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
- Terraform runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/)

View file

@ -83,3 +83,26 @@ A review is complete when it records, briefly:
Do not write a monolithic review body or a validation transcript into the PR
surface; keep comments inline and high-signal.
Infra and application **PRs** stay separate. GitHub Actions owns SPA content
(`deploy-web.yaml`). HCP Terraform owns the bucket and CloudFront. A change set
that includes both `terraform/` and deployable application files (`src/`,
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either
side.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.
- **Changes and value** — grouped by area, each with the value it delivers.
- **Ticket** — the board key(s) when one applies; "None" otherwise.
- Link any ADR relied upon.
Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View file

@ -0,0 +1,256 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
function testUser(userRoles: string) {
return {
token: "dashboard-visual-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles,
phoneNumber: "5551234567",
fullname: "Dashboard Visual User",
id: "1",
};
}
const DISPATCHERS = [
{ id: "1", name: "Pat Dispatcher" },
{ id: "2", name: "Riley Chen" },
{ id: "3", name: "Sam Okafor" },
];
const STATS = {
total: 128,
open: 52,
notDispatched: 8,
completed: 64,
dueCount: 40,
completedDueCount: 30,
completionRate: 82,
averageResolutionDays: 3.4,
scheduledTomorrow: 6,
pendingUplifts: 4,
avetaPending: 3,
breakdown: [
{ status: "PM", count: 24 },
{ status: "Emergency", count: 6 },
{ status: "Reactive", count: 12 },
{ status: "Overdue", count: 9 },
],
statusDistribution: [
{ status: "Scheduled", count: 30 },
{ status: "In Progress", count: 18 },
{ status: "Completed", count: 64 },
{ status: "Pending", count: 16 },
],
};
const WORKLOAD = {
items: [
{
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
openWorkOrders: 12,
totalWorkOrders: 20,
},
{ dispatcherId: "2", dispatcherName: "Riley Chen", openWorkOrders: 7, totalWorkOrders: 15 },
],
page: 1,
pageSize: 10,
totalDispatchers: 2,
};
const PERFORMANCE = {
items: [
{
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
completionRate: 94,
averageResolutionDays: 2.5,
assignedCount: 20,
completedCount: 18,
},
{
dispatcherId: "2",
dispatcherName: "Riley Chen",
completionRate: 78,
averageResolutionDays: 3.1,
assignedCount: 15,
completedCount: 11,
},
{
dispatcherId: "3",
dispatcherName: "Sam Okafor",
completionRate: 61,
averageResolutionDays: 4.4,
assignedCount: 9,
completedCount: 5,
},
],
page: 1,
pageSize: 10,
totalDispatchers: 3,
};
const REGIONS = {
items: [
{ region: "East", workOrderCount: 44 },
{ region: "Central", workOrderCount: 31 },
{ region: "West", workOrderCount: 22 },
{ region: "California", workOrderCount: 18 },
{ region: "Unmapped/Other", workOrderCount: 5 },
],
};
const VENDOR_INSIGHTS = {
items: [
{
vendorId: "101",
vendorName: "Gateway Plumbing",
completionRate: 88,
rescheduleRate: 12,
averageResolutionDays: 2.9,
totalJobs: 40,
},
{
vendorId: "102",
vendorName: "Summit HVAC",
completionRate: 76,
rescheduleRate: 20,
averageResolutionDays: 3.6,
totalJobs: 28,
},
],
};
const TREND = {
granularity: "week",
buckets: [
{
date: "2026-07-27",
label: "Jul 27",
total: 18,
open: 4,
completed: 12,
canceled: 1,
overdue: 1,
isCurrent: false,
},
{
date: "2026-08-03",
label: "Aug 3",
total: 22,
open: 6,
completed: 13,
canceled: 1,
overdue: 2,
isCurrent: false,
},
{
date: "2026-08-10",
label: "Aug 10",
total: 26,
open: 8,
completed: 15,
canceled: 0,
overdue: 3,
isCurrent: false,
},
{
date: "2026-08-17",
label: "Aug 17",
total: 20,
open: 9,
completed: 9,
canceled: 1,
overdue: 1,
isCurrent: true,
},
],
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockDashboardApi(page: Page, userRoles: string) {
await page.clock.setFixedTime(new Date(FROZEN_NOW));
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
testUser(userRoles),
);
await page.route("**/api/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
// Only stub real backend calls; let Vite dev module requests (e.g.
// /src/domain/auth/api/*.ts) through untouched.
if (!pathname.startsWith("/api/")) {
await route.continue();
return;
}
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
if (pathname.includes("/Dashboard/Performance"))
return fulfillJson(route, { data: PERFORMANCE });
if (pathname.includes("/Dashboard/Regions")) return fulfillJson(route, { data: REGIONS });
if (pathname.includes("/Dashboard/VendorInsights")) {
return fulfillJson(route, { data: VENDOR_INSIGHTS });
}
if (pathname.includes("/Dashboard/Trend")) return fulfillJson(route, { data: TREND });
if (pathname.includes("/workorders/lookups/dispatchers")) {
return fulfillJson(route, { data: DISPATCHERS });
}
return fulfillJson(route, { data: [] });
});
}
async function openDashboard(page: Page, userRoles: string) {
await mockDashboardApi(page, userRoles);
await page.goto("/");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `
*, *::before, *::after { transition: none !important; animation: none !important; }
.MuiDrawer-paper { transform: none !important; }
.Toastify__toast-container { display: none !important; }
.tsqd-parent-container { display: none !important; }
`,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(page).toHaveScreenshot(name, { fullPage: true });
}
test.describe("Dashboard deterministic pixel regression", () => {
test("admin dashboard", async ({ page }) => {
await openDashboard(page, "Admin");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByText("Status Distribution")).toBeVisible();
await expect(page.getByText("Dispatcher Performance")).toBeVisible();
// The scope picker is available to Admin/Scheduler (SH-336).
await expect(page.getByRole("button", { name: /All dispatchers/ })).toBeVisible();
await expectStableScreenshot(page, "dashboard-admin.png");
});
test("dispatcher scoped dashboard", async ({ page }) => {
await openDashboard(page, "Dispatcher");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// A Dispatcher gets the static "My WOs" label and no dispatcher tables (SH-336).
await expect(page.getByText("My WOs")).toBeVisible();
await expect(page.getByText("Dispatcher Performance")).toHaveCount(0);
await expect(page.getByText("Status Distribution")).toBeVisible();
await expectStableScreenshot(page, "dashboard-dispatcher.png");
});
});

View file

@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
const workOrder = { kind: "workOrder", tab: "info" };
await page.route("**/api/notifications", (route) =>
fulfillJson(route, {
items: [
generatedAt: "2026-07-23T12:00:00Z",
sections: [
{
id: "no-vendor-501",
type: "NoVendor",
reason: "no-vendor",
label: "No Vendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
count: 1,
items: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
id: "no-vendor-501",
reason: "no-vendor",
severity: "Medium",
rowType: "dismissable",
title: "WO #WO-501 starts within 48h with no vendor assigned",
count: 1,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [{ id: 501, number: "WO-501" }],
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
},
{
reason: "vendor-conflict",
label: "Vendor Conflict",
severity: "Medium",
count: 2,
items: [
{
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
rowType: "dismissable",
title: "Acme has overlapping appointments",
count: 2,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [
{ id: 501, number: "WO-501" },
{ id: 502, number: "WO-502" },
],
},
],
},
],
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
});
test("filters insights and exposes both export formats", async ({ page }) => {

View file

@ -268,7 +268,7 @@ test("vendor revises a changes-requested uplift using its existing evidence", as
});
});
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => {
const decisions: Array<{ path: string; body: unknown }> = [];
const statuses = new Map<number, string>([
[41, "Pending"],
@ -284,73 +284,95 @@ test("internal approver can approve and request changes with audited notes", asy
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/);
if (action && request.method() === "POST") {
const id = Number(action[1]);
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
statuses.set(
id,
action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected",
);
decisions.push({ path, body: request.postDataJSON() });
await fulfillJson(route, { id, status: statuses.get(id) });
return;
}
if (path.endsWith("/uplifts/can-approve")) {
await fulfillJson(route, { canApprove: true });
return;
}
const requestedStatus = new URL(request.url()).searchParams.get("status");
const items = [
{
id: 41,
dispatchId: 7,
workOrderId: 501,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
workOrderId: 502,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
].filter((item) => requestedStatus == null || item.status === requestedStatus);
await fulfillJson(route, {
total: 2,
items: [
{
id: 41,
dispatchId: 7,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
],
total: items.length,
items,
});
});
await page.goto("/uplifts");
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve" }).click();
await page.getByRole("button", { name: "Confirm" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Request Changes" }).click();
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Confirm" }).click();
await changesRow.getByRole("button", { name: "Reject" }).click();
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Reject uplift" }).click();
await page.getByRole("tab", { name: "Approved" }).click();
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvedRow.getByRole("button", { name: "Revoke" }).click();
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
await page.getByRole("button", { name: "Revoke $125" }).click();
await expect
.poll(() => decisions)
.toEqual([
{ path: "/api/uplifts/41/approve", body: {} },
{
path: "/api/uplifts/42/request-changes",
path: "/api/uplifts/42/reject",
body: { note: "Please itemize labor and parts." },
},
{
path: "/api/uplifts/41/revoke",
body: { note: "Approval was made in error." },
},
]);
});

View file

@ -29,6 +29,8 @@ const activeVendors = [
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
AreaId: 1 as number | null,
AreaName: "East",
},
{
Id: 2,
@ -47,9 +49,16 @@ const activeVendors = [
Notes: "",
TotalJobs: 18,
IsActive: true,
AreaId: null as number | null,
AreaName: "",
},
];
const SERVICE_AREAS = [
{ id: 2, name: "Central" },
{ id: 1, name: "East" },
];
const inactiveVendors = [
{
...activeVendors[0],
@ -97,6 +106,7 @@ async function mockVendorApi(
listError?: boolean;
deactivationBlocked?: boolean;
deleteConflict?: boolean;
groupedDirectory?: boolean;
legacyGateway?: boolean;
longText?: boolean;
} = {},
@ -134,6 +144,7 @@ async function mockVendorApi(
googleMapsUrl: vendor.GoogleMapsUrl,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
areas: SERVICE_AREAS,
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
@ -147,7 +158,7 @@ async function mockVendorApi(
},
}),
);
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => {
const url = new URL(route.request().url());
state.listUrls.push(url.toString());
if (options.listError && url.searchParams.get("pageSize") !== "1") {
@ -159,17 +170,54 @@ async function mockVendorApi(
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const areas = [...url.searchParams.entries()]
.filter(([key]) => key.startsWith("areas["))
.map(([, value]) => value);
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
(!company || vendor.CompanyName === company) &&
(areas.length === 0 ||
areas.some((area) =>
area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area,
)),
);
const isCount = url.searchParams.get("pageSize") === "1";
const responseRows = filtered.map((vendor) => {
if (!options.groupedDirectory) return vendor;
const technicians = [
{
Id: vendor.Id,
ContactName: vendor.ContactName,
Email: vendor.Email,
Phone: vendor.Phone,
TradeSpecialties: vendor.TradeSpecialties,
IsActive: vendor.IsActive,
TotalJobs: vendor.TotalJobs,
},
];
if (vendor.Id === 1) {
technicians.push({
Id: 91,
ContactName: "Robin Rivers",
Email: "robin@gateway.test",
Phone: "314-555-0191",
TradeSpecialties: "Drain Cleaning",
IsActive: true,
TotalJobs: 18,
});
}
return {
...vendor,
TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0),
Technicians: technicians,
};
});
await fulfillJson(route, {
Data: isCount ? filtered.slice(0, 1) : filtered,
Data: isCount ? responseRows.slice(0, 1) : responseRows,
TotalCount: filtered.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
@ -213,6 +261,8 @@ async function mockVendorApi(
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
technicians: [
...vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
@ -276,6 +326,8 @@ async function mockVendorApi(
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
technicians: vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
.map((vendor) => ({
@ -367,6 +419,44 @@ async function mockVendorApi(
}
test.describe("Vendor directory prototype parity", () => {
test("renders company-owned columns and the grouped technician roster", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await mockVendorApi(page, { groupedDirectory: true });
await page.goto("/vendors");
const expectedHeaders = [
"Company",
"Company Phone",
"Company Email",
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
"Total Jobs",
"Status",
];
await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1);
for (const header of expectedHeaders) {
await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible();
}
const gatewayRow = page.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
await expect(gatewayRow).toContainText("Adam Whyte");
await expect(gatewayRow).toContainText("90");
const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge");
await expect(rosterBadge).toHaveText("+1");
await rosterBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
await rosterBadge.focus();
await expect(rosterBadge).toBeFocused();
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
});
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
@ -381,21 +471,25 @@ test.describe("Vendor directory prototype parity", () => {
await expect(heading).toHaveCSS("font-weight", "800");
await expect(heading).toHaveCSS("line-height", "33px");
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
await expect(page.getByText("2 active companies").first()).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const heading of [
"Company",
"Technician",
"Location",
"Company Phone",
"Company Email",
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Phone",
"Email",
"Technician",
"Technician Phone",
"Total Jobs",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible();
}
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
@ -403,20 +497,30 @@ test.describe("Vendor directory prototype parity", () => {
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
47,
);
expect((await page.locator("table").boundingBox())?.width).toBe(2200);
expect(
(await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox())
?.height,
).toBe(47);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active technicians")).toBeVisible();
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
await expect(tradeBadge).toHaveText("+1");
await tradeBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
await tradeBadge.focus();
await expect(tradeBadge).toBeFocused();
await tradeBadge.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await tradeBadge.press("Enter");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("2 active companies").last()).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
true,
);
@ -433,8 +537,8 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
await expect(page.getByText("1 company · filtered by")).toBeVisible();
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
@ -442,7 +546,7 @@ test.describe("Vendor directory prototype parity", () => {
).toBe(true);
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No inactive technicians found")).toBeVisible();
await expect(page.getByText("No inactive companies found")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
@ -457,10 +561,10 @@ test.describe("Vendor directory prototype parity", () => {
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 },
{ cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email },
{ cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address },
{ cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
{ cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
];
for (const expected of fields) {
@ -587,10 +691,17 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
// SH-271: one combined "Address (optional)" line became three required
// fields, and the hand-pasted Google Maps URL input is gone — the location
// is derived from the address now.
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
"1 Market St",
);
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)");
await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
@ -643,9 +754,15 @@ test.describe("Vendor directory prototype parity", () => {
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
// SH-271: the separate "Open in Google Maps" row is gone — the Street
// Address itself is the link, built from the structured parts.
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
await expect(streetLink).toHaveAttribute("target", "_blank");
await expect(streetLink).toHaveAttribute(
"href",
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
);
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
@ -655,6 +772,55 @@ test.describe("Vendor directory prototype parity", () => {
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
test("filters by Service Area and lets an admin assign one", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
const gatewayRow = page.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" });
await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East");
await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned");
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByPlaceholder("All areas").click();
await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]);
await page.getByRole("option", { name: "East" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(
page.getByRole("button", { name: "Remove filter Service Area: East" }),
).toBeVisible();
await expect(gatewayRow).toBeVisible();
await expect(metroRow).toHaveCount(0);
expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe(
true,
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByRole("button", { name: "Clear all" }).last().click();
await page.getByPlaceholder("All areas").click();
await page.getByRole("option", { name: "Unassigned" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Service Area: Unassigned")).toBeVisible();
await expect(metroRow).toBeVisible();
await expect(gatewayRow).toHaveCount(0);
await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" });
await expect(areaField).toBeEnabled();
await areaField.click();
await page.getByRole("option", { name: "Central" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.areaId).toBe(2);
expect(state.updatedBody?.state).toBe("MO");
});
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
@ -693,10 +859,19 @@ test.describe("Vendor directory prototype parity", () => {
).toBeEnabled();
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
// SH-271 made the structured address required. This test is about keyboard
// dismissal and free-text company creation, so the address is filled to
// leave those as the only things it is exercising.
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
await page.getByLabel("City (required)").fill("St. Louis");
await page.getByLabel("State (required)").fill("Missouri");
await page.getByRole("option", { name: "Missouri (MO)" }).click();
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
expect(state.createdBody?.state).toBe("MO");
expect(state.createdBody?.areaId).toBeNull();
});
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {

View file

@ -70,6 +70,13 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
// The vendor address map preview embeds maps.google.com in an iframe. Its
// content depends on the network, on timing, and on whatever Google serves
// that minute, none of which belongs in a test with "deterministic pixel
// regression" in its name. Aborting the request pins the frame to a blank
// box, so the shot measures our layout and nothing else.
await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort());
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
@ -112,7 +119,7 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "
return;
}
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
if (url.pathname.endsWith("/api/Vendor/GetVendorDirectoryList")) {
const isCount = url.searchParams.get("pageSize") === "1";
if (mode === "error" && !isCount) {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
@ -236,8 +243,12 @@ test.describe("Vendor deterministic pixel regression", () => {
test("empty", async ({ page }) => {
await openVendorPage(page, "empty");
await expect(page.getByText("No active technicians found")).toBeVisible();
const emptyState = page.getByText("No active companies found");
await expect(emptyState).toBeVisible();
await expectStableScreenshot(page, "vendor-empty.png");
await page.setViewportSize({ width: 390, height: 844 });
await expect(emptyState).toBeInViewport();
});
test("error", async ({ page }) => {

View file

@ -0,0 +1,185 @@
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
// Completion uploads from a mobile-sized viewport must reach the API as
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
test.use({ ...devices["Pixel 7"] });
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const ROW_VERSION = "AAAAAAAAB9E=";
const TEST_USER = {
token: "wo-completion-uploads-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
// Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf).
const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1");
const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex");
const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex");
const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex");
type BoardRowOptions = { id: number; woNumber: string; workOrderType: number };
function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) {
return {
id,
woNumber,
workOrderType,
siteCode: "BK5",
lifecycleStatusLabel: "In Progress",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
docStatus: "No",
rowVersion: ROW_VERSION,
};
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function multipartBoundary(request: Request): string | undefined {
const contentType = request.headers()["content-type"] ?? "";
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
}
function multipartFileName(body: string): string | undefined {
return /filename="([^"]+)"/.exec(body)?.[1];
}
async function hideQueryDevtools(page: Page) {
await page.addStyleTag({
content:
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
});
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
for (const node of nodes) {
node.remove();
}
});
}
async function mockBoard(page: Page, row: ReturnType<typeof boardRow>) {
await page.route("**/api/**", (route) => {
const pathname = new URL(route.request().url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
return fulfillJson(route, { data: [] });
});
await page.route("**/api/workorders/board*", (route) => {
if (route.request().url().includes("board/search")) return route.fallback();
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [row],
});
});
await page.route("**/api/workorders/board/search*", (route) =>
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
);
}
async function openCompletionTab(page: Page, woNumber: string) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.goto("/workorders");
await hideQueryDevtools(page);
const row = page.getByRole("row").filter({ hasText: woNumber });
await row.getByRole("button", { name: "View details" }).click();
const panel = page.getByRole("dialog", { name: "Work order details" });
await expect(panel).toBeVisible({ timeout: 15_000 });
await panel.getByRole("tab", { name: "Completion Doc" }).click();
return panel;
}
test.describe("Completion uploads on a mobile viewport", () => {
test("uploads a signed PDF as multipart with the file and work order version", async ({
page,
}) => {
const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 });
await mockBoard(page, row);
await page.route("**/api/workorders/71/detail", (route) =>
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
);
let upload: Request | undefined;
await page.route("**/api/workorders/71/completion-doc", (route) => {
upload = route.request();
return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" });
});
const panel = await openCompletionTab(page, row.woNumber);
const chooser = page.waitForEvent("filechooser");
await panel.getByRole("button", { name: "Upload signed PDF" }).click();
await (
await chooser
).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF });
await expect.poll(() => upload?.method()).toBe("POST");
const boundary = multipartBoundary(upload!);
expect(boundary, upload!.headers()["content-type"]).toBeTruthy();
const body = upload!.postDataBuffer()?.toString("latin1") ?? "";
expect(body).toContain(`--${boundary}`);
expect(body).toContain('name="file"; filename="Signed Completion.pdf"');
expect(body).toContain("%PDF-1.7");
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
});
test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => {
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
const media: { id: number; category: number; url: string; fileName: string }[] = [];
await mockBoard(page, row);
await page.route("**/api/workorders/72/detail", (route) =>
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }),
);
const uploads: { contentType: string; body: string }[] = [];
await page.route("**/api/workorders/72/media", (route) => {
const request = route.request();
const body = request.postDataBuffer()?.toString("latin1") ?? "";
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
const fileName = multipartFileName(body) ?? "file";
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
media.push(item);
return fulfillJson(route, item);
});
let categorize: Request | undefined;
await page.route("**/api/workorders/72/media/*", (route) => {
categorize = route.request();
const item = media[0]!;
return fulfillJson(route, { ...item, category: 1 });
});
const panel = await openCompletionTab(page, row.woNumber);
const chooser = page.waitForEvent("filechooser");
await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click();
const files = [
{ name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG },
{ name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 },
{ name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV },
];
await (await chooser).setFiles(files);
await expect.poll(() => uploads.length).toBe(files.length);
for (const { name } of files) {
const upload = uploads.find((item) => multipartFileName(item.body) === name);
expect(upload, `multipart upload for ${name}`).toBeTruthy();
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
}
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
await expect.poll(() => categorize?.method()).toBe("PATCH");
const boundary = multipartBoundary(categorize!);
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
});
});

View file

@ -0,0 +1,80 @@
import { expect, test, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "due-date-floor-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
test.describe("Due Date floor in the Create WO wizard (SH-356)", () => {
test.setTimeout(60_000);
test("disables days before Schedule On and clears Due Date when Schedule On passes it", async ({
page,
}) => {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => {
localStorage.setItem("auth", JSON.stringify(user));
const style = document.createElement("style");
style.textContent = ".tsqd-parent-container { display: none !important; }";
document.documentElement.appendChild(style);
}, TEST_USER);
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
if (route.request().method() !== "GET") {
await fulfillJson(route, {}, 500);
return;
}
await fulfillJson(route, {
data: [],
items: [],
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 0, total: 0 },
unscheduled: [],
scheduled: [],
});
},
);
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: /Pick a date/ }).click();
const dateTab = page.getByRole("button", { name: "Date", exact: true });
if (await dateTab.isVisible()) {
await dateTab.click();
}
await page.getByRole("button", { name: "20", exact: true }).click();
await page.getByRole("button", { name: /No due date/ }).click();
await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "21", exact: true }).click();
await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0);
await page.getByRole("button", { name: /Aug 20/ }).click();
const dateTabAgain = page.getByRole("button", { name: "Date", exact: true });
if (await dateTabAgain.isVisible()) {
await dateTabAgain.click();
}
await page.getByRole("button", { name: "25", exact: true }).click();
await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible();
});
});

View file

@ -0,0 +1,265 @@
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [BOARD_ROW],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});

View file

@ -0,0 +1,174 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wizard-vendor-notes-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const LOCATIONS = [
{
id: 10,
name: "STL-01 · St. Louis, MO",
siteCode: "STL-01",
city: "St. Louis",
state: "MO",
code: "STL-01",
},
];
const VENDORS = [
{
id: 101,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Market St",
},
];
type Captured = { method: string; path: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockApi(page: Page, captured: Captured[]) {
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const { pathname } = new URL(request.url());
if (request.method() !== "GET") {
captured.push({ method: request.method(), path: pathname, body: request.postDataJSON() });
}
if (pathname.endsWith("/workorders/board") && request.method() === "POST") {
await fulfillJson(route, { data: { id: 900, woNumber: "SH00900" } });
return;
}
if (pathname.endsWith("/vendor-company-roster/55") && request.method() === "PATCH") {
await fulfillJson(route, {
companyId: 55,
rowVersion: "rv-2",
name: "Gateway Plumbing",
notes: "Gate code 4411",
technicians: [],
});
return;
}
if (pathname.endsWith("/vendor-company-roster") && request.method() === "GET") {
await fulfillJson(route, {
companyId: 55,
rowVersion: "rv-1",
name: "Gateway Plumbing",
notes: "Call before arrival",
technicians: [{ id: 101, contactName: "Adam Whyte", isActive: true }],
});
return;
}
if (pathname.includes("workorders/board")) {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 0, total: 0 },
unscheduled: [],
scheduled: [],
items: [],
});
return;
}
if (pathname.endsWith("/locations/10")) {
await fulfillJson(route, {
data: {
id: 10,
name: "STL-01 · St. Louis, MO",
contact: "Jamie Site",
phone: "3145550100",
zipCode: "63101",
},
});
return;
}
if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) {
await fulfillJson(route, { data: LOCATIONS });
return;
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
await fulfillJson(route, { data: VENDORS });
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
test.describe("wizard vendor company notes (SH-321)", () => {
test.setTimeout(60_000);
test("pre-fills the company notes and saves edits to the vendor company on create", async ({
page,
}) => {
const captured: Captured[] = [];
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => {
localStorage.setItem("auth", JSON.stringify(user));
const style = document.createElement("style");
style.textContent = ".tsqd-parent-container { display: none !important; }";
document.documentElement.appendChild(style);
}, TEST_USER);
await mockApi(page, captured);
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select site/ }).click();
await page.getByRole("button", { name: "STL-01 · St. Louis, MO" }).first().click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await page.getByRole("button", { name: /Select technician/ }).click();
await page
.getByRole("button", { name: /Adam Whyte/ })
.last()
.first()
.click();
const notes = page.getByPlaceholder("Contact preferences, availability, etc.");
await expect(notes).toHaveValue("Call before arrival");
await notes.fill("Gate code 4411");
await page.getByRole("button", { name: /Create/ }).click();
await expect
.poll(() => captured.find((c) => c.method === "PATCH"))
.toEqual({
method: "PATCH",
path: "/api/vendor-company-roster/55",
body: {
rowVersion: "rv-1",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
},
});
expect(captured.some((c) => c.method === "POST" && c.path.endsWith("/workorders/board"))).toBe(
true,
);
});
});

View file

@ -0,0 +1,121 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-number-sh-placeholder-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function hideQueryDevtools(page: Page) {
await page.addStyleTag({
content:
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
});
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
for (const node of nodes) {
node.remove();
}
});
}
const REAL_ROW = {
id: 1000,
woNumber: "12345678901",
workOrderType: 2,
siteCode: "DAL1",
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
};
async function mockBoard(page: Page, patches: unknown[]) {
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const url = request.url();
if (request.method() !== "GET") {
patches.push({ url, body: request.postDataJSON() });
await fulfillJson(route, {}, 500);
return;
}
if (url.includes("workorders/board/search")) {
await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 });
return;
}
if (url.includes("workorders/board")) {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [REAL_ROW],
});
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
test.describe("inline WO number SH placeholder", () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => {
localStorage.setItem("auth", JSON.stringify(user));
}, TEST_USER);
});
test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({
page,
}) => {
const mutations: unknown[] = [];
await mockBoard(page, mutations);
await page.goto("/workorders");
await hideQueryDevtools(page);
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("12345678901").first()).toBeVisible();
await page
.getByRole("button", { name: /Add WO/ })
.first()
.click();
await page
.getByText(/^Insert WO #/)
.first()
.click();
const draftInput = page.getByLabel("Work order number");
await draftInput.fill("SH00001");
await draftInput.press("Enter");
await expect(draftInput).toHaveCount(0);
await expect(page.getByText("SH00001", { exact: true })).toBeVisible();
await expect(page.getByText(/must be 11 digits/)).toHaveCount(0);
await page.getByText("12345678901", { exact: true }).first().click();
const savedInput = page.getByLabel("Work order number");
await savedInput.fill("SH00009");
await savedInput.press("Enter");
await expect(
page.getByText("Cannot replace an APM work order number with an internal number"),
).toBeVisible();
await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible();
expect(mutations).toEqual([]);
});
});

View file

@ -204,6 +204,12 @@ async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error"
});
}
async function expectWorkOrderPageReady(page: Page) {
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 90_000,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
@ -217,15 +223,18 @@ async function expectStableScreenshot(page: Page, name: string) {
}
test.describe("Work Orders deterministic pixel regression", () => {
test.setTimeout(120_000);
test("list", async ({ page }) => {
await openWorkOrderPage(page);
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
await expect(page.getByText("WO-501").first()).toBeVisible();
await expectStableScreenshot(page, "wo-list.png");
});
test("filters", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await page.getByRole("button", { name: "Advanced Filters" }).click();
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
await expectStableScreenshot(page, "wo-filters.png");
@ -233,6 +242,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("new", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await expectStableScreenshot(page, "wo-new.png");
@ -240,6 +250,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("detail", async ({ page }) => {
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
const row = page.locator("#wo-row-1");
await row.hover();
await row.getByRole("button", { name: "View details" }).click();
@ -249,7 +260,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("empty", async ({ page }) => {
await openWorkOrderPage(page, "empty");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
await page.getByLabel("Search work orders").fill("zz");
await expect(page.getByText("No work orders match your search")).toBeVisible();
await expectStableScreenshot(page, "wo-empty.png");
@ -257,7 +268,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("error", async ({ page }) => {
await openWorkOrderPage(page, "error");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
await expectWorkOrderPageReady(page);
const alert = page.getByRole("main").getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText(/server error/i);
@ -267,6 +278,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
test("mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openWorkOrderPage(page);
await expectWorkOrderPageReady(page);
await expect(page.getByText("WO-501").first()).toBeVisible();
await expectStableScreenshot(page, "wo-mobile.png");

106
package-lock.json generated
View file

@ -3137,14 +3137,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -3158,8 +3158,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.10",
"vitest": "4.1.10"
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -3168,16 +3168,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -3186,13 +3186,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -3213,9 +3213,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3226,13 +3226,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -3240,14 +3240,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -3256,9 +3256,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -3266,13 +3266,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -4378,9 +4378,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
@ -6942,19 +6942,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -6982,12 +6982,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -13,12 +13,11 @@
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs",
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",

View file

@ -6,7 +6,7 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
// Port 4173 isolates Playwright from the app's normal port 3000 server.

View file

@ -6,7 +6,7 @@ export default defineConfig({
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
workers: 2,
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
outputDir: "test-results/visual",
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",

View file

@ -10,8 +10,11 @@ from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
@ -22,7 +25,12 @@ DISTRIBUTION_ADDRESS = (
"module.environment_owned.aws_cloudfront_distribution.site"
)
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
ROLE_POLICY_ADDRESS = "module.environment_owned.aws_iam_role_policy.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
BUCKET_POLICY_ADDRESS,
ROLE_ADDRESS,
ROLE_POLICY_ADDRESS,
}
OWNERSHIP_TAGS = {
"Environment": None,
"ManagedBy": "terraform",
@ -330,6 +338,113 @@ def _validate_policy_update(
return violations
def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": (
"sts.amazonaws.com"
),
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
)
def _validate_role_assume_policy(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
before_policy, violations = _parse_policy(
before.get("assume_role_policy"), address, "before"
)
after_policy, after_violations = _parse_policy(
after.get("assume_role_policy"), address, "after"
)
violations.extend(after_violations)
if before_policy == after_policy:
violations.append(f"{address}: assume_role_policy semantics did not change")
expected_after = _expected_github_deploy_assume_policy(environment)
if after_policy is not None and after_policy != expected_after:
violations.append(
f"{address}: post-adoption assume_role_policy semantics are not exact"
)
return violations
def _validate_role_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
allowed_roots = {"tags", "tags_all", "assume_role_policy", "description"}
invalid = {path for path in changed if not path or path[0] not in allowed_roots}
violations = [
f"{address}: controlled role update changes forbidden path {'.'.join(path)}"
for path in sorted(invalid)
]
if not changed:
violations.append(f"{address}: update has no changed leaf values")
expected = {
**OWNERSHIP_TAGS,
"Environment": environment,
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"],
}
expected_after = {key: value for key, value in expected.items() if value is not None}
for tag_attribute in ("tags", "tags_all"):
if after.get(tag_attribute) != expected_after:
violations.append(
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
)
if any(path and path[0] == "assume_role_policy" for path in changed):
violations.extend(
_validate_role_assume_policy(address, before, after, environment)
)
return violations
def _validate_iam_policy_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
) -> list[str]:
changed = _changed_leaf_paths(before, after)
if changed != {("policy",)}:
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
if before.get("policy") == after.get("policy"):
return [f"{address}: policy semantics did not change"]
return []
def _validate_controlled_update(
address: str,
change: dict[str, Any],
@ -348,6 +463,10 @@ def _validate_controlled_update(
return [*violations, f"{address}: controlled update requires before/after objects"]
if address in TAG_UPDATE_ADDRESSES:
violations.extend(_validate_tag_update(address, before, after, environment))
elif address == ROLE_ADDRESS:
violations.extend(_validate_role_update(address, before, after, environment))
elif address == ROLE_POLICY_ADDRESS:
violations.extend(_validate_iam_policy_update(address, before, after))
elif address == BUCKET_POLICY_ADDRESS:
violations.extend(
_validate_policy_update(
@ -416,19 +535,30 @@ def check_plan(
if change.get("replace_paths") not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
import_id = REQUIRED_IMPORT_IDS[environment].get(address)
if mode == "import":
if actions != ["no-op"]:
violations.append(
f"{address}: import mode requires no-op, got {actions!r}"
)
if expected_type is not None:
violations.extend(
_validate_import_metadata(
address=address,
change=change,
environment=environment,
if address in ALLOWED_CREATE_ADDRESSES and import_id is None:
if actions != ["create"]:
violations.append(
f"{address}: import mode requires create for deploy parameters, got {actions!r}"
)
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden for created deploy parameters"
)
else:
if actions != ["no-op"]:
violations.append(
f"{address}: import mode requires no-op, got {actions!r}"
)
if expected_type is not None:
violations.extend(
_validate_import_metadata(
address=address,
change=change,
environment=environment,
)
)
)
elif mode == "post-import":
if actions != ["no-op"]:
violations.append(
@ -456,6 +586,8 @@ def check_plan(
distribution_id,
)
)
elif actions == ["create"] and address in ALLOWED_CREATE_ADDRESSES:
pass
elif actions != ["no-op"]:
violations.append(f"{address}: unsafe controlled actions {actions!r}")

View file

@ -1,137 +0,0 @@
// Terraform/application change isolation gate.
//
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
// on the workspace. If the same merge also changes deployable application
// code, the content release and the VCS run race for the workspace lock
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
// or discarded by a human before the next content release.
//
// Files that may accompany a Terraform change without triggering a release:
// the Terraform tree itself, its plan-guard tooling, and documentation.
//
// Usage:
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
//
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
// it only when the PR carries the `terraform-isolation-override` label, which
// reviewers grant to the rare change that must introduce Terraform variables
// together with the workflow that consumes them. The checker has no memory of
// a previous pass: the same mixed diff fails again as soon as the override
// env is unset (label removal).
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const OVERRIDE_LABEL = "terraform-isolation-override";
export function isTerraformPath(file) {
return file.startsWith("terraform/");
}
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
// Terraform change for the mixed-PR check.
export function isTerraformInfrastructurePath(file) {
return isTerraformPath(file) && !file.endsWith(".md");
}
export function mayAccompanyTerraform(file) {
if (isTerraformPath(file)) return true;
if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
if (
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
file,
)
) {
return true;
}
if (file.startsWith("scripts/testdata/terraform-")) return true;
return false;
}
/**
* @param {string[]} files changed paths relative to the repository root
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
*/
export function classifyChangedFiles(files) {
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
const terraform = unique.filter(isTerraformInfrastructurePath);
const application = unique.filter((file) => !mayAccompanyTerraform(file));
return {
terraform,
application,
mixed: terraform.length > 0 && application.length > 0,
};
}
function changedFilesFromGit(base, head) {
const mergeBase = execFileSync("git", ["merge-base", base, head], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
{ cwd: ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
}
function parseArgs(argv) {
const options = { base: null, head: "HEAD", stdin: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--base") options.base = argv[++index];
else if (argument === "--head") options.head = argv[++index];
else if (argument === "--stdin") options.stdin = true;
else throw new Error(`unknown argument: ${argument}`);
}
if (!options.stdin && !options.base) {
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
}
return options;
}
function main(argv) {
const options = parseArgs(argv);
const files = options.stdin
? readFileSync(0, "utf8").split("\n")
: changedFilesFromGit(options.base, options.head);
const result = classifyChangedFiles(files);
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
console.log("─".repeat(64));
console.log(
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
);
if (!result.mixed) {
console.log(" PASS: Terraform and application changes are not mixed");
return 0;
}
console.log(" Terraform files:");
for (const file of result.terraform) console.log(` ${file}`);
console.log(" Application files that cannot ship in the same PR:");
for (const file of result.application) console.log(` ${file}`);
if (override) {
console.log(
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
);
return 0;
}
console.log(
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
);
return 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exit(main(process.argv.slice(2)));
}

View file

@ -1,179 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
OVERRIDE_LABEL,
classifyChangedFiles,
isTerraformInfrastructurePath,
mayAccompanyTerraform,
} from "./check-terraform-isolation.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"check-terraform-isolation.mjs",
);
function runGate(files, env = {}) {
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
input: `${files.join("\n")}\n`,
encoding: "utf8",
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
});
}
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
for (const file of [
"terraform/live/dev/main.tf",
"terraform/live/modules/environment-owned/main.tf",
"terraform/README.md",
"README.md",
"docs/adr/0003-terraform.md",
"scripts/check-terraform-import-plan.py",
"scripts/terraform_import_plan_resources.py",
"scripts/test-terraform-import-plan-check.py",
"scripts/terraform-validate.mjs",
"scripts/check-terraform-isolation.mjs",
"scripts/check-terraform-release-plan.py",
"scripts/hcp-run-guard.py",
"scripts/test-hcp-run-guard.py",
"scripts/verify-cloudfront-release.sh",
"scripts/test-verify-cloudfront-release.sh",
"scripts/summarize-cloudfront-live-state.sh",
"scripts/check-github-workflows.sh",
"scripts/read-release-pointer.py",
"scripts/testdata/terraform-release-plans/version-only.json",
]) {
assert.equal(mayAccompanyTerraform(file), true, file);
}
});
test("application, workflow, and dependency files count as application changes", () => {
for (const file of [
"src/App.tsx",
"public/favicon.ico",
"index.html",
"package.json",
"package-lock.json",
".env.production",
"vite.config.ts",
".github/workflows/deploy.yml",
"scripts/deploy-web.sh",
"scripts/governance-check.mjs",
"e2e/login.spec.ts",
]) {
assert.equal(mayAccompanyTerraform(file), false, file);
}
});
test("terraform-only and application-only changes are not mixed", () => {
assert.equal(
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
false,
);
assert.equal(
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
false,
);
assert.equal(classifyChangedFiles([]).mixed, false);
});
test("terraform documentation does not mix with application or workflow changes", () => {
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
assert.equal(
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
false,
);
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
assert.match(docsOnly.stdout, /PASS/);
});
test("terraform plus application is mixed and lists the offending files", () => {
const result = classifyChangedFiles([
"terraform/live/dev/main.tf",
"src/App.tsx",
"README.md",
" ",
"src/App.tsx",
]);
assert.equal(result.mixed, true);
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
assert.deepEqual(result.application, ["src/App.tsx"]);
});
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
assert.match(mixed.stdout, /FAIL/);
assert.match(mixed.stdout, /src\/App\.tsx/);
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
assert.match(isolated.stdout, /PASS/);
});
test("CLI override downgrades a mixed change to a warning that names the label", () => {
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /WARNING/);
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "yes",
});
assert.equal(notTrue.status, 1);
});
test("removing the override fails a mixed change that was previously green", () => {
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
const previouslyGreen = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
assert.match(previouslyGreen.stdout, /WARNING/);
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
// the string "false" after the label is removed. A stale green check must
// not survive that.
const afterLabelRemoved = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "false",
});
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
assert.match(afterLabelRemoved.stdout, /FAIL/);
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
});
test("CLI refuses to run without a base ref or --stdin", () => {
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
assert.notEqual(result.status, 0);
});
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
const workflows = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
".github/workflows",
);
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
}
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
});

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fail when a change set mixes Terraform with deployable application files.
Workflow, docs, and gate-script changes may travel with either side.
"""
from __future__ import annotations
import argparse
import sys
APP_ROOTS = (
"src/",
"public/",
"pages/",
"config/",
)
APP_FILES = {
"index.html",
"vite.config.ts",
"vitest.config.ts",
}
def is_terraform_path(path: str) -> bool:
return path == "terraform" or path.startswith("terraform/")
def is_app_path(path: str) -> bool:
normalized = path.replace("\\", "/")
if normalized in APP_FILES:
return True
if normalized in {"src", "public", "pages", "config"}:
return True
if normalized.startswith(APP_ROOTS):
return True
if normalized.startswith("tsconfig"):
return True
return normalized.startswith(".env")
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
app_files = sorted({path for path in paths if is_app_path(path)})
if terraform_files and app_files:
return terraform_files, app_files
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
help="Changed paths. Omit and pass newline-separated paths on stdin.",
)
args = parser.parse_args()
paths = list(args.paths)
if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()]
violation = isolation_violation(paths)
if violation is None:
print("PASS: application and Terraform changes are isolated")
return 0
terraform_files, app_files = violation
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
print("terraform:", file=sys.stderr)
for path in terraform_files:
print(f" {path}", file=sys.stderr)
print("application:", file=sys.stderr)
for path in app_files:
print(f" {path}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,79 +0,0 @@
#!/usr/bin/env bash
#
# Content publish step for the environment deploy workflows
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
#
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
# environment's S3 bucket with the right cache headers, and invalidates
# CloudFront. It never touches infrastructure.
#
# Runs from the repo root. The target is resolved from, in order:
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
# dev, whose CloudFormation outputs disappear during Terraform adoption)
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
set -euo pipefail
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
REGION="${AWS_REGION:-us-east-1}"
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
npm ci
npm run build
BUCKET="${SITE_BUCKET:-}"
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
exit 1
else
echo "Reading stack outputs from ${STACK_NAME}..."
stack_output() {
aws cloudformation describe-stacks \
--stack-name "${STACK_NAME}" \
--region "${REGION}" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
exit 1
fi
fi
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
# Everything except index.html: long-lived + immutable, prune stale objects.
aws s3 sync dist/ "s3://${BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
echo "Uploading index.html (never cached)..."
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
echo "Invalidating CloudFront ${DIST_ID}..."
INVALIDATION_ID="$(aws cloudfront create-invalidation \
--distribution-id "${DIST_ID}" \
--paths "/*" \
--query 'Invalidation.Id' \
--output text)"
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
aws cloudfront wait invalidation-completed \
--distribution-id "${DIST_ID}" \
--id "${INVALIDATION_ID}"
fi
echo "Web deploy complete."

View file

@ -0,0 +1,29 @@
/**
* Live G13 scheduling and how a GitHub event is split into change sets.
*
* Isolation is a per-change rule. pull_request and local runs classify the
* merge-base...HEAD range (one PR). merge_group classifies each first-parent
* commit vs its parent (one queued PR per squash or merge-commit). The group
* union is not a change set: a Terraform-only PR stacked with an app-only PR
* must still pass. push is not classified; landing already happened.
*/
export function shouldRunLiveIsolation(eventName) {
return !eventName || eventName === "pull_request" || eventName === "merge_group";
}
export function usesPerCommitIsolation(eventName) {
return eventName === "merge_group";
}
/**
* File lists to run through check_app_terraform_isolation.py.
* `commitDiffs` is first-parent order (oldest first); ignored except on
* merge_group.
*/
export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) {
if (usesPerCommitIsolation(eventName)) {
return commitDiffs.map((commit) => commit.files);
}
return [rangeFiles];
}

View file

@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
@ -21,12 +23,11 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform release-plan contract", "test:terraform-release-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"],
["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"],
["App/Terraform isolation tests", "test:app-terraform-isolation"],
];
function isGoverned(relativePath) {
@ -142,7 +143,7 @@ function godfileRatchet(baseRef) {
function resolveBaseRef() {
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
for (const candidate of ["origin/dev", "origin/main"]) {
for (const candidate of ["origin/main", "origin/dev"]) {
try {
execFileSync("git", ["rev-parse", "--verify", candidate], {
cwd: ROOT,
@ -219,6 +220,79 @@ function runRepositoryGate(label, script) {
return { label, status: result.status, error: result.error };
}
function classifyIsolationPaths(files) {
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT,
encoding: "utf8",
input: files.length > 0 ? `${files.join("\n")}\n` : "",
});
}
function firstParentCommitDiffs(baseRef) {
const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]);
return shas.map((sha) => ({
sha,
files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]),
}));
}
function runIsolationGate(baseRef, eventName) {
if (usesPerCommitIsolation(eventName)) {
const commits = firstParentCommitDiffs(baseRef);
return {
mode: "per-commit",
results: commits.map((commit) => ({
...classifyIsolationPaths(commit.files),
sha: commit.sha,
})),
};
}
// Diff from the merge base, not the moving base tip. A two-dot diff against
// a branch that has advanced reports everything the base gained after the
// branch point as if this change reverted it.
const mergeBase = gitText(["merge-base", baseRef, "HEAD"]);
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]);
return {
mode: "range",
mergeBase,
results: [{ ...classifyIsolationPaths(files), sha: null }],
};
}
function recordIsolationFailures(isolation, failures) {
const startError = isolation.results.find((result) => result.error);
if (startError) {
failures.push(`G13: could not start: ${startError.error.message}`);
}
if (isolation.results.some((result) => !result.error && result.status !== 0)) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
function logIsolationGate(baseRef, isolation) {
if (isolation.mode === "per-commit") {
console.log(
`G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`,
);
for (const result of isolation.results) {
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const prefix = result.sha ? result.sha.slice(0, 7) : "commit";
if (output) {
console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`);
}
}
return;
}
const mergeBase = isolation.mergeBase ?? "unresolvable";
console.log(
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
);
const result = isolation.results[0];
if (!result) return;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
@ -317,6 +391,34 @@ function main() {
}
}
console.log("─".repeat(64));
const eventName = process.env.GITHUB_EVENT_NAME;
if (!shouldRunLiveIsolation(eventName)) {
console.log(
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
);
} else if (!baseRef) {
console.log("G13: application and Terraform isolation (no base...HEAD)");
console.log(" FAIL (no valid base ref)");
failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
let isolation;
try {
isolation = runIsolationGate(baseRef, eventName);
} catch (error) {
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
console.log(" FAIL (could not resolve isolation diffs)");
const message = error instanceof Error ? error.message : String(error);
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
}
if (isolation) {
logIsolationGate(baseRef, isolation);
recordIsolationFailures(isolation, failures);
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);

View file

@ -1,29 +0,0 @@
#!/usr/bin/env python3
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
from __future__ import annotations
import json
import os
import sys
def main() -> int:
raw = sys.stdin.read().strip()
data = json.loads(raw) if raw else {}
current = data.get("current") or ""
previous = data.get("previous") or ""
output_path = os.environ["GITHUB_OUTPUT"]
with open(output_path, "a", encoding="utf-8") as handle:
handle.write(f"live_current={current}\n")
handle.write(f"live_previous={previous}\n")
print(
"Pointer live current="
+ (current or "<empty>")
+ " previous="
+ (previous or "<empty>")
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash
# Print pointer body, origin paths, distribution status, and served index hash.
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
# Print origin paths, distribution status, and served index hash.
# Used by deploy-web.yaml's always() summary. Never fails the job.
set -u
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
SITE_URL="${SITE_URL:-}"
echo "=== CloudFront live state ==="
echo "pointer:"
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
echo
if [[ -z "${DISTRIBUTION_ID}" ]]; then
echo "DISTRIBUTION_ID unset"
exit 0
fi
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
import json, sys
payload = json.load(sys.stdin)
@ -19,5 +19,7 @@ for origin in ((config.get("Origins") or {}).get("Items") or []):
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
'
echo
echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
if [[ -n "${SITE_URL}" ]]; then
echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL%/}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
fi

View file

@ -1,18 +1,18 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
const ENVIRONMENTS = ["dev"];
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
const LIVE_ROOTS = ["terraform/live/dev", "terraform/live/staging"];
function run(args, cwd = ROOT) {
function run(args, cwd = ROOT, env = process.env) {
const result = spawnSync(TERRAFORM, args, {
cwd,
encoding: "utf8",
stdio: "inherit",
env,
});
if (result.error) {
throw new Error(`could not start Terraform: ${result.error.message}`, {
@ -24,12 +24,13 @@ function run(args, cwd = ROOT) {
}
}
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
for (const root of ROOTS) {
// -backend=false never touches HCP state; -lockfile=readonly refuses to
// silently rewrite the committed provider lock.
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
run(["validate", "-no-color"], root);
run(["fmt", "-check", "-recursive", "terraform"]);
for (const liveRoot of LIVE_ROOTS) {
const abs = path.join(ROOT, liveRoot);
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], abs);
run(["validate", "-no-color"], abs);
}
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
console.log(
"Terraform formatting and validation passed for terraform/live/dev and terraform/live/staging.",
);

View file

@ -1,8 +1,7 @@
"""Canonical frontend Terraform ownership and import-ID maps.
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
are kept so the checker can prove that a dev plan carrying a staging identifier
is rejected; they do not authorize a staging import.
Dev is already in HCP state. Staging constants authorize the first import of
the live CDK stack onto terraform/live/staging.
"""
COMMON_RESOURCES = {
@ -31,6 +30,10 @@ COMMON_RESOURCES = {
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
"module.environment_owned.aws_ssm_parameter.deploy_bucket": "aws_ssm_parameter",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": (
"aws_ssm_parameter"
),
}
REQUIRED_RESOURCES = {
@ -45,9 +48,22 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
"module.environment_owned.aws_cloudfront_distribution.site",
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
"module.environment_owned.aws_iam_role.github_deploy",
"module.environment_owned.aws_iam_role_policy.github_deploy",
}
)
ALLOWED_CREATE_ADDRESSES = frozenset(
{
"module.environment_owned.aws_ssm_parameter.deploy_bucket",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id",
}
)
GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new"
GITHUB_OIDC_PROVIDER_ARN = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
ENVIRONMENT_CONFIG = {
"dev": {
"bucket_name": "seahaven-shoc-frontend-dev",
@ -103,6 +119,8 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-dev:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
},
"staging": {
**_bucket_imports("seahaven-shoc-frontend-staging"),
@ -126,5 +144,7 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-staging:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
},
}

View file

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
liveIsolationFileSets,
shouldRunLiveIsolation,
usesPerCommitIsolation,
} from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py");
const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"];
const APP_ONLY = ["src/app/routes.tsx"];
function classify(files) {
const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" });
return result.status;
}
function anySetFails(fileSets) {
return fileSets.some((files) => classify(files) !== 0);
}
describe("shouldRunLiveIsolation", () => {
it("runs locally and on pull_request", () => {
assert.equal(shouldRunLiveIsolation(undefined), true);
assert.equal(shouldRunLiveIsolation(""), true);
assert.equal(shouldRunLiveIsolation("pull_request"), true);
});
it("runs on merge_group so the required check classifies the candidate", () => {
assert.equal(shouldRunLiveIsolation("merge_group"), true);
assert.equal(usesPerCommitIsolation("merge_group"), true);
});
it("skips push and other CI events", () => {
assert.equal(shouldRunLiveIsolation("push"), false);
assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false);
assert.equal(usesPerCommitIsolation("pull_request"), false);
});
});
describe("liveIsolationFileSets", () => {
it("classifies the PR range as one change set", () => {
const range = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("pull_request", range, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [range]);
assert.equal(anySetFails(sets), true);
});
it("classifies each queued PR, not the merge-group union", () => {
const union = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", union, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]);
assert.equal(anySetFails(sets), false);
assert.equal(classify(union), 1);
});
it("fails a single queued PR that mixes terraform and app files", () => {
const mixed = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]);
assert.equal(anySetFails(sets), true);
});
});

View file

@ -14,8 +14,11 @@ from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
@ -27,7 +30,7 @@ BUCKET = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY}
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
def import_id(environment: str, address: str) -> str:
@ -83,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
}
def github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
@ -146,6 +183,19 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
def policy_change(environment: str, address: str) -> dict[str, Any]:
if address == DEPLOY_POLICY:
return {
"actions": ["update"],
"before": {"policy": json.dumps({"Version": "2012-10-17", "Statement": []})},
"after": {
"policy": json.dumps(
{
"Version": "2012-10-17",
"Statement": [{"Sid": "ListWebBucket", "Effect": "Allow"}],
}
)
},
}
if address != BUCKET_POLICY:
raise AssertionError(f"{address} is not a reviewed policy update")
return {
@ -165,10 +215,14 @@ def make_plan(
updates = controlled_updates or set()
for address, resource_type in REQUIRED_RESOURCES[environment].items():
if mode == "import":
change: dict[str, Any] = {
"actions": ["no-op"],
"importing": {"id": import_id(environment, address)},
}
import_id_value = REQUIRED_IMPORT_IDS[environment][address]
if import_id_value is None:
change = {"actions": ["create"]}
else:
change = {
"actions": ["no-op"],
"importing": {"id": import_id_value},
}
elif mode == "post-import":
change = {"actions": ["no-op"]}
elif address in updates:
@ -177,6 +231,8 @@ def make_plan(
if address in TAG_ADDRESSES
else policy_change(environment, address)
)
elif address in ALLOWED_CREATE_ADDRESSES:
change = {"actions": ["create"]}
else:
change = {"actions": ["no-op"]}
if address == DISTRIBUTION:
@ -259,8 +315,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
source = (
REPOSITORY
/ "terraform/live/modules/environment-owned/main.tf"
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
expected = """ spa_rewrite_code = join("\\n", [
"function handler(event) {",
@ -275,55 +330,41 @@ class ImportPlanCheckerTests(unittest.TestCase):
])"""
self.assertIn(expected, source)
def test_only_dev_has_a_live_root(self) -> None:
live_roots = sorted(
path.name
for path in (REPOSITORY / "terraform/live").iterdir()
if path.is_dir() and path.name != "modules"
)
self.assertEqual(["dev"], live_roots)
def test_live_roots_are_not_flattened(self) -> None:
live = REPOSITORY / "terraform" / "live"
self.assertTrue((live / "dev" / "versions.tf").is_file())
self.assertTrue((live / "dev" / "main.tf").is_file())
self.assertTrue((live / "staging" / "versions.tf").is_file())
self.assertTrue((live / "staging" / "main.tf").is_file())
self.assertTrue((live / "modules" / "environment-owned" / "main.tf").is_file())
self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists())
self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists())
def test_dev_root_pins_adoption_complete_in_code(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n")
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
self.assertNotIn('variable "adoption_complete"', source)
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
self.assertNotIn(
"variable ",
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
root_file,
)
def test_adoption_complete_is_pinned_in_locals(self) -> None:
dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text(
encoding="utf-8"
)
self.assertRegex(dev, r"adoption_complete\s+= true")
self.assertRegex(staging, r"adoption_complete\s+= true")
self.assertNotIn('variable "adoption_complete"', dev)
self.assertNotIn('variable "environment"', dev)
self.assertNotIn('variable "release_version_label"', dev)
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
expected = {
"dev": (
"local.hosted_zone_id",
"local.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
"hosted_zone_id": "local.hosted_zone_id",
"certificate_arn": "local.certificate_arn",
"github_oidc_provider_arn": "local.github_oidc_arn",
"cache_policy_id": "local.cache_policy_id",
}
for environment, values in expected.items():
source = (
REPOSITORY / f"terraform/live/{environment}/main.tf"
).read_text(encoding="utf-8")
for name, value in zip(
(
"hosted_zone_id",
"certificate_arn",
"github_oidc_provider_arn",
"cache_policy_id",
),
values,
strict=True,
):
self.assertIn(f"{name}", source)
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
self.assertNotRegex(
source,
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
)
for name, value in expected.items():
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
self.assertNotRegex(
source,
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
)
def test_exact_import_plan_passes_for_every_environment(self) -> None:
for environment in REQUIRED_RESOURCES:
@ -409,7 +450,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
resource(plan, ROLE)["change"]["after"]["max_session_duration"] = 7200
self.assert_fails(plan, "dev", ROLE)
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
@ -420,12 +461,34 @@ class ImportPlanCheckerTests(unittest.TestCase):
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
self.assert_fails(plan, "dev", BUCKET)
def test_role_trust_change_is_rejected(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
self.assert_fails(plan, "dev", ROLE)
def test_role_trust_change_is_allowed(self) -> None:
for environment in REQUIRED_RESOURCES:
plan = make_plan(
environment, mode="controlled", controlled_updates={ROLE}
)
role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(
github_deploy_assume_policy(environment)
)
with self.subTest(environment=environment):
self.assert_passes(plan, environment, ROLE)
def test_role_trust_rejects_mutated_document(self) -> None:
for mutation in ("principal", "missing-sub"):
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
policy = github_deploy_assume_policy("dev")
if mutation == "principal":
policy["Statement"][0]["Principal"] = {"AWS": "*"}
else:
del policy["Statement"][0]["Condition"]["StringEquals"][
"token.actions.githubusercontent.com:sub"
]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(policy)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", ROLE)
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
for mutation in ("principal", "extra"):
@ -452,40 +515,70 @@ class ImportPlanCheckerTests(unittest.TestCase):
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
def test_github_deploy_policy_is_bucket_root_sync(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document)
self.assertIn("ListReleasePrefixes", document)
self.assertIn("PublishReleasePrefix", document)
self.assertIn("ReadReleasePointer", document)
self.assertIn("ReadDistribution", document)
self.assertIn("ListWebBucket", document)
self.assertIn("SyncWebBucket", document)
self.assertIn("InvalidateDistribution", document)
self.assertIn("DeployParams", document)
self.assertIn("s3:DeleteObject", document)
self.assertIn("cloudfront:CreateInvalidation", document)
self.assertIn("cloudfront:GetInvalidation", document)
self.assertIn("cloudfront:GetDistribution", document)
self.assertIn("cloudfront:GetDistributionConfig", document)
self.assertIn("releases/*", document)
self.assertIn("ssm:GetParameter", document)
self.assertNotIn("ListReleasePrefixes", document)
self.assertNotIn("PublishReleasePrefix", document)
self.assertNotIn("ReadReleasePointer", document)
self.assertNotIn("releases/*", document)
self.assertNotIn("AssumeCdkBootstrapRoles", document)
self.assertNotIn("DescribeStack", document)
self.assertNotIn("CreateInvalidation", document)
self.assertNotIn("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn(
self.assertIn(
"module.environment_owned.aws_iam_role_policy.github_deploy",
CONTROLLED_UPDATE_ADDRESSES,
)
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", DEPLOY_POLICY)
def test_github_deploy_assume_document_matches_module(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split(
'data "aws_iam_policy_document" "github_deploy_assume" {', 1
)[1]
document = document.split(
'data "aws_iam_policy_document" "github_deploy" {', 1
)[0]
self.assertIn("GithubDeployOidc", document)
self.assertIn("sts:AssumeRoleWithWebIdentity", document)
self.assertIn("token.actions.githubusercontent.com:aud", document)
self.assertIn("sts.amazonaws.com", document)
self.assertIn("token.actions.githubusercontent.com:sub", document)
self.assertIn("local.github_subject", document)
self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document)
self.assertIn("deploy-web.yaml@refs/heads/main", document)
self.assertIn("deploy-web.yaml@refs/tags/v*", document)
def test_deploy_policy_controlled_update_passes(self) -> None:
self.assert_passes(
make_plan(
"dev",
mode="controlled",
controlled_updates={DEPLOY_POLICY},
),
"dev",
DEPLOY_POLICY,
)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
resource(plan, DEPLOY_POLICY)["change"] = {
"actions": ["update"],
"before": {"policy": "{}"},
"after": {"policy": '{"Version":"2012-10-17"}'},
}
self.assert_fails(plan, "dev", DEPLOY_POLICY)
self.assert_fails(plan, "dev")
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:

View file

@ -4,11 +4,8 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
INDEX_HTML='<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>'
INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n'
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
failures=0
@ -29,12 +26,7 @@ make_stubs() {
cat > "${bin}/aws" << 'AWS'
#!/usr/bin/env bash
set -euo pipefail
state_dir="${STUB_STATE}"
if [[ "${1:-}" == "s3" ]]; then
cat "${state_dir}/pointer.json"
exit 0
fi
cat "${state_dir}/distribution.json"
cat "${STUB_STATE}/distribution.json"
AWS
cat > "${bin}/curl" << 'CURL'
#!/usr/bin/env bash
@ -75,12 +67,11 @@ if [[ "${url}" == *"/assets/"* ]]; then
[[ -z "${output}" ]] && printf '%s' "${body}"
exit 0
fi
body="$(cat "${state_dir}/index.html")"
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
if [[ -n "${output}" ]]; then
printf '%s' "${body}" > "${output}"
cat "${state_dir}/index.html" > "${output}"
else
printf '%s' "${body}"
cat "${state_dir}/index.html"
fi
exit 0
CURL
@ -96,149 +87,94 @@ print(json.dumps({
"Status": status,
"DistributionConfig": {
"Origins": {"Items": [
{"Id": "current", "OriginPath": path},
{"Id": "previous", "OriginPath": ""},
{"Id": "current", "OriginPath": path}
]}
}
}
}))' "${status}" "${current_path}"
}
pointer_json() {
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
}
run_case() {
local name="$1"
local dir
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}"
export PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com"
export SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=3
export INTERVAL=0
local log="${dir}/log.txt"
set +e
bash "${VERIFY}" > "${log}" 2>&1
local code=$?
set -e
assert_exit "${name}" "$2" "${code}" "${log}"
rm -rf "${dir}"
}
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
# 1. Empty origin, then propagates (InProgress -> Deployed, hash already matches).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
printf 'InProgress\n' > "${dir}/status"
cat > "${dir}/bin/aws" << AWS
#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" == "s3" ]]; then
cat "${dir}/pointer.json"
exit 0
fi
status="\$(cat "${dir}/status")"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":""}]}}}}))' "\${status}"
echo Deployed > "${dir}/status"
AWS
chmod +x "${dir}/bin/aws"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=5 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt"
assert_exit "empty-origin-then-propagates" 0 "${code}" "${dir}/log.txt"
rm -rf "${dir}"
}
# 2. Right config never propagates (Deployed, stale hash).
# 2. Empty origin never propagates (Deployed, stale hash).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf 'stale' > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt"
assert_exit "empty-origin-never-propagates" 1 "${code}" "${dir}/log.txt"
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 3. Wrong origin path fails fast.
# 3. Non-empty origin path fails fast.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/deadbeef" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); }
assert_exit "nonempty-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: nonempty origin path did not name origin_path" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 4. Wrong pointer fails fast.
# 4. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "InProgress" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 5. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
@ -249,40 +185,40 @@ AWS
rm -rf "${dir}"
}
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL.
# 5. Hash-matched Deployed release whose JS assets omit the baked API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const x=1;' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); }
grep -q "baked API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked API URL" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL.
# 6. Hash-matched Deployed release whose JS assets contain the staging API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Tests for check_app_terraform_isolation.isolation_violation."""
from __future__ import annotations
import unittest
from check_app_terraform_isolation import isolation_violation
class IsolationTests(unittest.TestCase):
def test_terraform_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/dev/main.tf",
"terraform/live/README.md",
]
)
)
def test_app_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"src/app/routes.tsx",
"public/favicon.ico",
"index.html",
]
)
)
def test_docs_workflows_and_gate_scripts_with_terraform(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/modules/environment-owned/main.tf",
".github/workflows/deploy-web.yaml",
"QUALITY_GATES.md",
"scripts/governance-check.mjs",
"scripts/check_app_terraform_isolation.py",
"package.json",
]
)
)
def test_mixed_src_and_terraform_fails(self) -> None:
violation = isolation_violation(
[
"terraform/live/dev/main.tf",
"src/app/routes.tsx",
]
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
self.assertEqual(app_files, ["src/app/routes.tsx"])
def test_mixed_vite_config_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/versions.tf", "vite.config.ts"])
self.assertIsNotNone(violation)
def test_mixed_env_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/main.tf", ".env.production"])
self.assertIsNotNone(violation)
if __name__ == "__main__":
unittest.main()

View file

@ -6,17 +6,17 @@ set -euo pipefail
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}"
if [[ -n "${RELEASE_LABEL}" ]]; then
RELEASE="${RELEASE_LABEL}"
else
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
exit 1
fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
exit 1
fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
if ! npm exec --no -- sentry-cli --version >/dev/null 2>&1; then
echo "sentry-cli is not in this SPA tree; skipping source-map upload"
exit 0
fi
npm exec --no -- sentry-cli sourcemaps upload \

View file

@ -1,121 +1,90 @@
#!/usr/bin/env bash
# Verify a CloudFront content release or rollback.
# Verify a CloudFront SPA deploy from deploy-web.yaml.
#
# Fail fast when origin_path or .release/current is the wrong label.
# Poll while the distribution is InProgress or the served index.html hash
# still matches the previous release. On timeout, print last observed state.
# Fail fast when any origin_path is still non-empty. Poll while the
# distribution is InProgress or the served index.html hash does not match
# the build. Then smoke-check caching headers, hashed assets, the baked
# API URL, and CORS against the target API.
set -euo pipefail
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
SITE_URL="${SITE_URL:-}"
SITE_BUCKET="${SITE_BUCKET:-}"
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
BUDGET="${BUDGET:-40}"
INTERVAL="${INTERVAL:-15}"
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_INDEX_SHA256 SITE_URL must be set." >&2
exit 2
fi
SITE_URL="${SITE_URL%/}"
if [[ -n "${EXPECTED_LABEL}" ]]; then
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
else
EXPECTED_PATH=""
fi
API_URL="${API_URL%/}"
sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
}
read_pointer() {
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
}
read_distribution_json() {
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
}
parse_distribution() {
python3 -c '
import json, os, sys
import json, sys
payload = json.load(sys.stdin)
dist = payload.get("Distribution") or payload
status = dist.get("Status") or "Unknown"
config = dist.get("DistributionConfig") or {}
origins = ((config.get("Origins") or {}).get("Items")) or []
paths = [origin.get("OriginPath") or "" for origin in origins]
expected = os.environ["EXPECTED_PATH"]
nonempty = [path for path in paths if path]
print(status)
print("\x1f".join(paths))
print("yes" if expected in paths else "no")
'
}
pointer_current() {
POINTER_BODY="$1" python3 -c '
import json, os
raw = os.environ.get("POINTER_BODY", "").strip()
if not raw:
print("")
raise SystemExit
print(json.loads(raw).get("current") or "")
print("yes" if nonempty else "no")
'
}
last_status="Unknown"
last_paths="Unknown"
last_pointer="Unknown"
last_hash="Unknown"
last_path_ok="no"
last_path_nonempty="no"
observe() {
last_pointer="$(read_pointer)"
local parsed
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
parsed="$(read_distribution_json | parse_distribution)"
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
local body
body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)"
if [[ -n "${body}" ]]; then
last_hash="$(printf '%s' "${body}" | sha256_of)"
last_path_nonempty="$(printf '%s\n' "${parsed}" | sed -n '3p')"
local hash
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
last_hash="${hash}"
else
last_hash="unreachable"
fi
}
report_state() {
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
echo "last observed: status=${last_status} origins=${last_paths} served_sha256=${last_hash}"
}
fail_fast_if_misconfigured() {
local current
current="$(pointer_current "${last_pointer}")"
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
report_state >&2
exit 1
fi
if [[ "${last_path_ok}" != "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
fail_fast_if_origin_path() {
if [[ "${last_path_nonempty}" == "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected empty bucket-root origins." >&2
report_state >&2
exit 1
fi
}
observe
fail_fast_if_misconfigured
fail_fast_if_origin_path
attempt=0
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
attempt=$((attempt + 1))
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
fail_fast_if_misconfigured
fail_fast_if_origin_path
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
break
fi
@ -141,6 +110,10 @@ for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
' "$1"
}
api_host() {
python3 -c 'import os,urllib.parse; print(urllib.parse.urlparse(os.environ["API_URL"]).hostname or "")'
}
assert_baked_api_url() {
local tmp="$1"
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
@ -167,15 +140,27 @@ assert_baked_api_url() {
exit 1
fi
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
local forbidden
for forbidden in api.staging.seahaven.com localhost:5141; do
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
local host
host="$(api_host)"
local forbidden=""
case "${host}" in
api.dev.seahaven.com) forbidden="api.staging.seahaven.com" ;;
api.staging.seahaven.com) forbidden="api.dev.seahaven.com" ;;
*)
echo "FAIL: API_URL host '${host}' is not a known SHOC API." >&2
exit 1
fi
done
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked dev API URL." >&2
;;
esac
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
exit 1
fi
if grep -Fq "localhost:5141" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL localhost:5141." >&2
exit 1
fi
if ! grep -Fq "${host}" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked API URL ${host}." >&2
exit 1
fi
}
@ -205,5 +190,5 @@ if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
exit 1
fi
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
echo "PASS: CloudFront release is Deployed, hash-matched, and smoke-clean."
report_state

View file

@ -30,7 +30,9 @@ export const API_PATHS = {
boardCreate: "workorders/board",
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -80,6 +82,11 @@ export const API_PATHS = {
deleteEmployees: "Employee/DeleteEmployees",
dropdown: "Employee/Dropdown",
},
teamMember: {
getList: "User",
create: "team-members",
detail: "team-members",
},
contact: {
getList: "Contact/GetContactList",
getContacts: "Contact/GetContacts",
@ -128,6 +135,7 @@ export const API_PATHS = {
},
vendor: {
getList: "Vendor/GetVendorList",
getDirectoryList: "Vendor/GetVendorDirectoryList",
getById: "Vendor/GetById",
create: "Vendor/Create",
update: "Vendor/Update",
@ -150,6 +158,11 @@ export const API_PATHS = {
},
dashboard: {
stats: "Dashboard/Stats",
workload: "Dashboard/Workload",
performance: "Dashboard/Performance",
regions: "Dashboard/Regions",
vendorInsights: "Dashboard/VendorInsights",
trend: "Dashboard/Trend",
},
dropdownOptions: {
byCategory: "DropdownOptions/ByCategory",
@ -157,6 +170,11 @@ export const API_PATHS = {
update: "DropdownOptions/Update",
delete: "DropdownOptions",
},
services: {
list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",
},

View file

@ -11,32 +11,22 @@ function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
}
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
export function shouldStripJsonContentType(body: unknown): body is FormData {
return body instanceof FormData;
}
export const api: KyInstance = ky.create({
prefix: resolveApiPrefixUrl(env.apiUrl),
// hazard: no instance-wide Content-Type. Ky sets application/json for `json` bodies, and the
// runtime must set `multipart/form-data; boundary=…` for FormData while it builds the Request.
// A default here is baked into that Request; deleting it in a hook leaves the multipart body
// with no Content-Type at all, so ASP.NET cannot bind [FromForm] fields.
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
({ request, options }) => {
({ request }) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
// why: the instance default is application/json. Deleting that header after
// Request construction drops the multipart boundary, so the API never sees `file`.
// Rebuild from the original FormData with no Content-Type so the runtime sets
// multipart/form-data including the boundary.
if (shouldStripJsonContentType(options.body)) {
request.headers.delete("Content-Type");
return new Request(request, { body: options.body });
}
},
],
beforeError: [({ error }) => normalizeApiRequestError(error)],

View file

@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
const theme = useTheme();
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<NotificationSessionProvider>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<Outlet />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
</Box>
</Box>
</Box>
</NotificationSessionProvider>
</ProtectedRoute>
);
}

View file

@ -1,240 +0,0 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
Alert,
Box,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
interface KpiCardProps {
title: string;
description?: string;
value?: string | number;
icon: ReactNode;
color: "success" | "primary" | "info" | "warning";
onClick: () => void;
loading?: boolean;
}
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
const colorMap = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
} as const;
return (
<Paper
variant="outlined"
onClick={onClick}
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
sx={{
borderLeftWidth: 4,
borderLeftStyle: "solid",
borderLeftColor: colorMap[color],
}}
>
<Box className="flex w-full gap-4 p-4">
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col justify-center gap-1">
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
{value !== undefined && (
<Typography variant="h4" component="p" className="font-bold leading-tight">
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{Boolean(description) && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
</Box>
</Box>
</Paper>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
icon={<AddIcon />}
color="success"
onClick={() => navigate("/workorders/new")}
/>
<KpiCard
title="Open Work Orders"
value={formatStat(stats?.open)}
icon={<FolderOpenOutlinedIcon />}
color="primary"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Not Dispatched"
value={formatStat(stats?.notDispatched)}
icon={<ScheduleOutlinedIcon />}
color="info"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Completed Orders"
value={formatStat(stats?.completed)}
icon={<CheckCircleOutlineIcon />}
color="warning"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
<TableCell align="right" className="font-semibold">
0-14 Days
</TableCell>
<TableCell align="right" className="font-semibold">
15-29 Days
</TableCell>
<TableCell align="right" className="font-semibold">
30-59 Days
</TableCell>
<TableCell align="right" className="font-semibold">
60+ Days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{PRIORITY_LEVELS.map((priority) => (
<TableRow key={priority}>
<TableCell>{priority}</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Invoice Analysis
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Count
</TableCell>
<TableCell align="right" className="font-semibold">
Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{INVOICE_STATUSES.map((status) => (
<TableRow key={status}>
<TableCell>{status}</TableCell>
<TableCell align="right">0</TableCell>
<TableCell align="right">$0.00</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,86 @@
import { Box, ButtonBase } from "@mui/material";
import { Text } from "@/components/ui/text";
export interface DashboardBarListRow {
id: string;
label: string;
count: number;
}
interface DashboardBarListProps {
rows: DashboardBarListRow[];
onSelectRow?: (row: DashboardBarListRow) => void;
isSelectable?: (row: DashboardBarListRow) => boolean;
ariaLabel: string;
}
function percentOf(count: number, max: number): number {
if (max <= 0 || count <= 0) {
return 0;
}
return Math.round((count / max) * 100);
}
function BarListRow({ row, width }: { row: DashboardBarListRow; width: number }) {
return (
<Box className="flex w-full flex-col gap-1 py-1.5">
<Box className="flex items-baseline justify-between gap-2">
<Text variant="description" className="truncate">
{row.label}
</Text>
<Text variant="description" tone="muted">
{row.count.toLocaleString()}
</Text>
</Box>
<Box className="h-2 w-full overflow-hidden rounded-full" sx={{ bgcolor: "action.hover" }}>
{width > 0 && (
<Box
data-testid="dashboard-bar-mark"
className="h-full rounded-full"
sx={{ width: `${width}%`, bgcolor: "primary.main" }}
/>
)}
</Box>
</Box>
);
}
export function DashboardBarList({
rows,
onSelectRow,
isSelectable,
ariaLabel,
}: DashboardBarListProps) {
const max = Math.max(...rows.map((row) => row.count), 0);
return (
<Box aria-label={ariaLabel} className="flex flex-col">
{rows.map((row) => {
const width = percentOf(row.count, max);
const selectable = Boolean(onSelectRow) && row.count > 0 && (isSelectable?.(row) ?? true);
return selectable ? (
<ButtonBase
key={row.id}
onClick={() => onSelectRow?.(row)}
sx={{
width: "100%",
textAlign: "left",
borderRadius: 1,
px: 1,
mx: -1,
cursor: "pointer",
}}
>
<BarListRow row={row} width={width} />
</ButtonBase>
) : (
<Box key={row.id} sx={{ px: 1, mx: -1 }}>
<BarListRow row={row} width={width} />
</Box>
);
})}
</Box>
);
}

View file

@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { Box, CircularProgress, Paper } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
interface DashboardCardProps {
title: string;
/** Optional scope caption shown beside the title (e.g. SH-349's all-time note). */
caption?: string;
isLoading?: boolean;
error?: unknown;
isEmpty: boolean;
emptyMessage: string;
children: ReactNode;
}
export function DashboardCard({
title,
caption,
isLoading = false,
error,
isEmpty,
emptyMessage,
children,
}: DashboardCardProps) {
return (
<Paper variant="outlined" className="flex flex-col overflow-hidden">
<Box className="flex items-baseline justify-between gap-2 border-b border-border px-4 py-3">
<Text variant="heading">{title}</Text>
{!!caption && (
<Text variant="description" tone="muted">
{caption}
</Text>
)}
</Box>
{isLoading && (
<Box className="flex items-center justify-center p-8">
<CircularProgress size={24} />
</Box>
)}
{!isLoading && Boolean(error) && <QueryErrorPanel compact className="m-4" />}
{!isLoading && !error && isEmpty && (
<Box className="p-6">
<Text variant="description" tone="muted">
{emptyMessage}
</Text>
</Box>
)}
{!isLoading && !error && !isEmpty && <Box className="p-4">{children}</Box>}
</Paper>
);
}

View file

@ -0,0 +1,67 @@
import { Box, Button, CircularProgress } from "@mui/material";
import { Users } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { DashboardRangeToggle } from "./dashboard-range-toggle";
import { DashboardScopePicker } from "./dashboard-scope-picker";
import type { DashboardScope } from "./dashboard-scope";
interface DashboardHeaderActionsProps {
isRefreshing: boolean;
showReset: boolean;
onResetFilters: () => void;
canViewAll: boolean;
scope: DashboardScope;
onScopeChange: (scope: DashboardScope) => void;
dispatcherUsers: WorkOrderUser[];
dispatcherUsersLoading: boolean;
currentUserId: string | number | undefined;
range: DashboardRangeKey;
onRangeChange: (range: DashboardRangeKey) => void;
}
export function DashboardHeaderActions({
isRefreshing,
showReset,
onResetFilters,
canViewAll,
scope,
onScopeChange,
dispatcherUsers,
dispatcherUsersLoading,
currentUserId,
range,
onRangeChange,
}: DashboardHeaderActionsProps) {
return (
<Box className="flex items-center gap-2">
{isRefreshing && <CircularProgress size={16} />}
{showReset && (
<Button size="small" onClick={onResetFilters}>
Reset filters
</Button>
)}
{canViewAll ? (
<DashboardScopePicker
scope={scope}
onChange={onScopeChange}
users={dispatcherUsers}
usersLoading={dispatcherUsersLoading}
currentUserId={currentUserId}
/>
) : (
<Box
className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
>
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
<Text variant="description" tone="muted">
My WOs
</Text>
</Box>
)}
<DashboardRangeToggle value={range} onChange={onRangeChange} />
</Box>
);
}

View file

@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import { Box, ButtonBase, Paper } from "@mui/material";
import { Text } from "@/components/ui/text";
type KpiTone = "success" | "primary" | "info" | "warning";
const toneColor: Record<KpiTone, string> = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
};
interface DashboardKpiCardProps {
title: string;
value?: number;
icon: ReactNode;
tone: KpiTone;
loading?: boolean;
active?: boolean;
onSelect?: () => void;
}
export function DashboardKpiCard({
title,
value,
icon,
tone,
loading = false,
active = false,
onSelect,
}: DashboardKpiCardProps) {
const color = toneColor[tone];
const content = (
<Box className="flex w-full items-center gap-4 p-4" sx={{ textAlign: "left" }}>
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${color}22`, color }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col gap-0.5">
<Text variant="label" tone="muted">
{title}
</Text>
<Text variant="label" sx={{ fontSize: 28, fontWeight: 700, lineHeight: 1.2 }}>
{loading ? "…" : (value ?? 0).toLocaleString()}
</Text>
</Box>
</Box>
);
return (
<Paper
variant="outlined"
className="min-h-[104px]"
sx={{ borderLeftWidth: 4, borderLeftStyle: "solid", borderLeftColor: color }}
>
{active && Boolean(onSelect) ? (
<ButtonBase sx={{ width: "100%", cursor: "pointer" }} onClick={onSelect}>
{content}
</ButtonBase>
) : (
content
)}
</Paper>
);
}

View file

@ -0,0 +1,42 @@
import { ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DASHBOARD_RANGES, type DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
const RANGE_LABELS: Record<DashboardRangeKey, string> = {
"this-week": "This week",
"last-3-months": "Last 3 months",
today: "Today",
"all-time": "All time",
};
interface DashboardRangeToggleProps {
value: DashboardRangeKey;
onChange: (value: DashboardRangeKey) => void;
disabled?: boolean;
}
export function DashboardRangeToggle({
value,
onChange,
disabled = false,
}: DashboardRangeToggleProps) {
return (
<ToggleButtonGroup
exclusive
size="small"
value={value}
disabled={disabled}
aria-label="Dashboard date range"
onChange={(_, next: DashboardRangeKey | null) => {
if (next) {
onChange(next);
}
}}
>
{DASHBOARD_RANGES.map((range) => (
<ToggleButton key={range} value={range}>
{RANGE_LABELS[range]}
</ToggleButton>
))}
</ToggleButtonGroup>
);
}

View file

@ -0,0 +1,118 @@
import { useMemo } from "react";
import { Popover, Skeleton } from "@mui/material";
import { ChevronDown, Users } from "lucide-react";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
import type { DashboardScope } from "./dashboard-scope";
type DashboardScopePickerProps = {
scope: DashboardScope;
onChange: (scope: DashboardScope) => void;
users: WorkOrderUser[];
usersLoading?: boolean;
currentUserId?: string | number;
};
export function DashboardScopePicker({
scope,
onChange,
users,
usersLoading = false,
currentUserId,
}: DashboardScopePickerProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
const dispatchers = useMemo(
() => [...users].sort((a, b) => a.name.localeCompare(b.name)),
[users],
);
const label = useMemo(() => {
if (scope.mode === "all") return "All dispatchers";
if (scope.mode === "mine") return "My WOs";
return dispatchers.find((user) => String(user.id) === scope.id)?.name ?? "Dispatcher";
}, [scope, dispatchers]);
const select = (next: DashboardScope) => {
onChange(next);
close();
};
if (usersLoading && users.length === 0) {
return (
<Skeleton
variant="rounded"
width={140}
height={18}
animation="wave"
aria-label="Loading dispatchers"
/>
);
}
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
aria-haspopup="listbox"
>
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
},
}}
>
<CheckboxMenuItem
checked={scope.mode === "all"}
label="All dispatchers"
onClick={() => select({ mode: "all" })}
/>
{meId != null && (
<CheckboxMenuItem
checked={scope.mode === "mine"}
label="My WOs only"
labelClassName="font-medium text-primary"
className="mb-1 pb-2"
style={{ borderBottom: "1px solid var(--color-border)" }}
onClick={() => select({ mode: "mine" })}
/>
)}
{dispatchers.map((user) => {
const id = String(user.id);
return (
<CheckboxMenuItem
key={id}
checked={scope.mode === "individual" && scope.id === id}
label={user.name}
right={
meId === id ? (
<span className="text-[10px] text-muted-foreground">me</span>
) : undefined
}
onClick={() => select({ mode: "individual", id })}
/>
);
})}
</Popover>
</>
);
}

View file

@ -0,0 +1,21 @@
/**
* SH-336 dispatcher-scope selection. Single-select — the Dashboard scopes to at
* most one dispatcher at a time (All company / My WOs / one individual), unlike
* the board's multi-select dispatcher filter.
*/
export type DashboardScope =
{ mode: "all" } | { mode: "mine" } | { mode: "individual"; id: string };
export const DASHBOARD_SCOPE_ALL: DashboardScope = { mode: "all" };
/** Resolves the scope to the `dispatcherId` query param, or null for company-wide. */
export function scopeDispatcherId(
scope: DashboardScope,
currentUserId: string | number | undefined,
): string | null {
if (scope.mode === "all") return null;
if (scope.mode === "mine") {
return currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
}
return scope.id || null;
}

View file

@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
import { Box, IconButton } from "@mui/material";
import ArrowBackOutlinedIcon from "@mui/icons-material/ArrowBackOutlined";
import ArrowForwardOutlinedIcon from "@mui/icons-material/ArrowForwardOutlined";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardTrendPoint } from "@/domain/dashboard/types/dashboard-trend";
import { useDashboardTrend } from "@/domain/dashboard/use-cases/use-dashboard-trend";
import {
businessTodayIso,
dashboardRangeParams,
} from "@/domain/dashboard/utils/dashboard-range-utils";
import { DashboardCard } from "./dashboard-card";
interface DashboardTrendCardProps {
range: DashboardRangeKey;
}
function trendParams(range: DashboardRangeKey, year: number) {
if (range === "all-time") return { year };
const params = dashboardRangeParams(range);
return {
...params,
...(range === "last-3-months" ? { range: "3m" as const } : {}),
};
}
function barHeight(point: DashboardTrendPoint, max: number): number {
if (point.total <= 0 || max <= 0) return 0;
return Math.max(6, Math.round((point.total / max) * 100));
}
function TrendBar({ point, max }: { point: DashboardTrendPoint; max: number }) {
const height = barHeight(point, max);
return (
<Box
className="flex min-w-[42px] flex-1 flex-col items-center gap-1"
data-testid="dashboard-trend-point"
sx={{ borderLeft: point.isCurrent ? "2px dashed" : undefined, borderColor: "warning.main" }}
>
<Text variant="description" tone="muted">
{point.total.toLocaleString()}
</Text>
<Box className="flex h-32 items-end">
{height > 0 && (
<Box
data-testid="dashboard-trend-bar"
className="w-6 rounded-t"
sx={{
height: `${height}%`,
bgcolor: point.isCurrent ? "warning.main" : "primary.main",
}}
/>
)}
</Box>
<Text variant="description" className="max-w-16 truncate text-center" title={point.label}>
{point.label}
</Text>
{point.isCurrent && <Text variant="description">Today</Text>}
</Box>
);
}
export function DashboardTrendCard({ range }: DashboardTrendCardProps) {
const currentYear = Number(businessTodayIso().slice(0, 4));
const [year, setYear] = useState(currentYear);
const params = useMemo(() => trendParams(range, year), [range, year]);
const trend = useDashboardTrend(params);
const points = trend.data?.points ?? [];
const max = Math.max(...points.map((point) => point.total), 0);
return (
<DashboardCard
title="Work Order Trend"
isLoading={trend.isLoading}
error={trend.error}
isEmpty={!trend.isLoading && !trend.error && points.length === 0}
emptyMessage="No work orders match the current filters."
>
<Box className="flex flex-col gap-3">
<Box className="flex items-center justify-between">
<Text variant="description" tone="muted">
{trend.data?.granularity ? `${trend.data.granularity} buckets` : "Trend"}
</Text>
{range === "all-time" && (
<Box className="flex items-center">
<IconButton
aria-label="Previous year"
size="small"
onClick={() => setYear((value) => value - 1)}
>
<ArrowBackOutlinedIcon fontSize="small" />
</IconButton>
<Text variant="description">{year}</Text>
<IconButton
aria-label="Next year"
size="small"
onClick={() => setYear((value) => value + 1)}
>
<ArrowForwardOutlinedIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
<Box
className="flex min-h-48 items-end gap-1 overflow-x-auto pb-1"
aria-label="Work Order Trend chart"
>
{points.map((point) => (
<TrendBar key={`${point.date}-${point.label}`} point={point} max={max} />
))}
</Box>
<Text variant="description" tone="muted">
The dashed marker and “Today” label identify the current business date.
</Text>
</Box>
</DashboardCard>
);
}

View file

@ -0,0 +1,137 @@
import { useNavigate } from "react-router";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import {
workOrderDispatcherDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherPerformanceCardProps {
rows: DispatcherPerformanceRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
function formatDays(value: number): string {
return (Math.round(value * 10) / 10).toLocaleString();
}
// why: SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
function completionRateColor(rate: number): string {
if (rate >= 90) return "success.main";
if (rate >= 70) return "warning.main";
return "error.main";
}
function DispatcherNameCell({
row,
range,
}: {
row: DispatcherPerformanceRow;
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
// why: A listed dispatcher is one with work in range; the drill-down filters Work Orders by dispatcher
// + range and never uses the completion rate, so gate on identity rather than the rate. (A
// dispatcher with assigned work but a 0% rate was previously inert here while drillable from
// Workload.) The gate stays on identity rather than `assignedCount > 0` so a legacy response without the SH-347 count field (assignedCount → 0) keeps its rows drillable.
const drillable = Boolean(row.dispatcherId);
if (!drillable) {
return <Text variant="description">{row.dispatcherName}</Text>;
}
return (
<Text
variant="description"
as="button"
className="cursor-pointer text-left font-medium underline-offset-2 hover:underline"
onClick={() =>
navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range)))
}
>
{row.dispatcherName}
</Text>
);
}
export function DispatcherPerformanceCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherPerformanceCardProps) {
return (
<DashboardCard
title="Dispatcher Performance"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Dispatcher Performance">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatcher</TableCell>
<TableCell align="right" className="font-semibold">
Assigned
</TableCell>
<TableCell align="right" className="font-semibold">
Completed
</TableCell>
<TableCell align="right" className="font-semibold">
Completion %
</TableCell>
<TableCell align="right" className="font-semibold">
Avg days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.dispatcherId || row.dispatcherName}>
<TableCell>
<DispatcherNameCell row={row} range={range} />
</TableCell>
<TableCell align="right">{row.assignedCount.toLocaleString()}</TableCell>
<TableCell align="right">{row.completedCount.toLocaleString()}</TableCell>
<TableCell
align="right"
sx={{ color: completionRateColor(row.completionRate), fontWeight: 600 }}
>
{`${row.completionRate.toLocaleString()}%`}
</TableCell>
<TableCell align="right">
{row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Performance pagination"
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,40 @@
import { Box, Pagination } from "@mui/material";
interface DispatcherTablePagerProps {
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
label: string;
}
/**
* 10-row pager for the dispatcher tables (SH-346/SH-347). Renders nothing until
* there is more than one page, so single-page tables are unchanged.
*/
export function DispatcherTablePager({
page,
pageSize,
totalDispatchers,
onPageChange,
label,
}: DispatcherTablePagerProps) {
const pageCount = pageSize > 0 ? Math.ceil(totalDispatchers / pageSize) : 1;
if (pageCount <= 1) {
return null;
}
return (
<Box className="mt-3 flex justify-end">
<Pagination
size="small"
color="primary"
count={pageCount}
page={page}
onChange={(_event, value) => onPageChange(value)}
aria-label={label}
/>
</Box>
);
}

View file

@ -0,0 +1,60 @@
import type { UseQueryResult } from "@tanstack/react-query";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import { DispatcherPerformanceCard } from "./dispatcher-performance-card";
import { DispatcherWorkloadCard } from "./dispatcher-workload-card";
interface DispatcherTablesSectionProps {
range: DashboardRangeKey;
workload: UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error>;
performance: UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error>;
workloadPage: number;
performancePage: number;
onWorkloadPageChange: (page: number) => void;
onPerformancePageChange: (page: number) => void;
}
/**
* The two paged dispatcher tables. Kept as a fragment so both cards stay direct
* children of the dashboard grid, and so the paging view-model derivation lives
* here rather than inflating the page component.
*/
export function DispatcherTablesSection({
range,
workload,
performance,
workloadPage,
performancePage,
onWorkloadPageChange,
onPerformancePageChange,
}: DispatcherTablesSectionProps) {
return (
<>
<DispatcherWorkloadCard
rows={workload.data?.items ?? []}
range={range}
page={workloadPage}
pageSize={workload.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={workload.data?.totalDispatchers ?? 0}
onPageChange={onWorkloadPageChange}
isLoading={workload.isLoading}
error={workload.error}
/>
<DispatcherPerformanceCard
rows={performance.data?.items ?? []}
range={range}
page={performancePage}
pageSize={performance.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={performance.data?.totalDispatchers ?? 0}
onPageChange={onPerformancePageChange}
isLoading={performance.isLoading}
error={performance.error}
/>
</>
);
}

View file

@ -0,0 +1,107 @@
import { useNavigate } from "react-router";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import {
workOrderDispatcherDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherWorkloadCardProps {
rows: DispatcherWorkloadRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
function DispatcherNameCell({
row,
range,
}: {
row: DispatcherWorkloadRow;
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
const drillable = Boolean(row.dispatcherId) && row.totalWorkOrders > 0;
if (!drillable) {
return <Text variant="description">{row.dispatcherName}</Text>;
}
return (
<Text
variant="description"
as="button"
className="cursor-pointer text-left font-medium underline-offset-2 hover:underline"
onClick={() =>
navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range)))
}
>
{row.dispatcherName}
</Text>
);
}
export function DispatcherWorkloadCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherWorkloadCardProps) {
return (
<DashboardCard
title="Dispatcher Workload"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Dispatcher Workload">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatcher</TableCell>
<TableCell align="right" className="font-semibold">
Open
</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.dispatcherId || row.dispatcherName}>
<TableCell>
<DispatcherNameCell row={row} range={range} />
</TableCell>
<TableCell align="right">{row.openWorkOrders.toLocaleString()}</TableCell>
<TableCell align="right">{row.totalWorkOrders.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Workload pagination"
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,55 @@
import { useNavigate } from "react-router";
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStatusBucketRow } from "@/domain/dashboard/types/dashboard-stats";
import {
workOrderDrilldownUrl,
workOrderStatusDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardBarList } from "./dashboard-bar-list";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No work orders match the current filters.";
const DRILLABLE_STATUSES = new Set<string>(ALL_WIZARD_STATUSES);
interface StatusDistributionCardProps {
rows: DashboardStatusBucketRow[];
range: DashboardRangeKey;
isLoading?: boolean;
error?: unknown;
}
export function StatusDistributionCard({
rows,
range,
isLoading = false,
error,
}: StatusDistributionCardProps) {
const navigate = useNavigate();
return (
<DashboardCard
title="Status Distribution"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Status Distribution"
rows={rows.map((row) => ({
id: row.status,
label: row.status,
count: row.count,
}))}
// why: Lifecycle labels the board can filter on are drillable; the "Unknown" bucket (unparseable
// statuses) has no board filter, so leave it inert.
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))
}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,87 @@
import { useState } from "react";
import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
import {
DEFAULT_DASHBOARD_RANGE,
type DashboardRangeKey,
} from "@/domain/dashboard/types/dashboard-range";
import { DASHBOARD_SCOPE_ALL, scopeDispatcherId, type DashboardScope } from "./dashboard-scope";
export interface DashboardScopeState {
canViewAll: boolean;
currentUserId: string | number | undefined;
range: DashboardRangeKey;
scope: DashboardScope;
/** The `dispatcherId` query param for the scoped endpoints (null = company-wide). */
dispatcherId: string | null;
workloadPage: number;
performancePage: number;
showReset: boolean;
setWorkloadPage: (page: number) => void;
setPerformancePage: (page: number) => void;
changeRange: (next: DashboardRangeKey) => void;
changeScope: (next: DashboardScope) => void;
resetFilters: () => void;
}
/**
* SH-336 dashboard scope/paging state. A Dispatcher (no
* `viewAllDispatchersOnDashboard`) is locked to their own work orders and sends
* no dispatcherId; Scheduler/Admin drive an explicit scope through the picker.
*/
export function useDashboardScope(): DashboardScopeState {
const { user } = useAuthContext();
const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles);
const [range, setRange] = useState<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
const [scope, setScope] = useState<DashboardScope>(DASHBOARD_SCOPE_ALL);
const [workloadPage, setWorkloadPage] = useState(1);
const [performancePage, setPerformancePage] = useState(1);
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
// why: A range or scope change re-scopes both dispatcher tables, so their page must reset — otherwise a
// switch while on page 3 lands on an out-of-range empty page that reads as a false "no
// dispatchers" state.
const resetPages = () => {
setWorkloadPage(1);
setPerformancePage(1);
};
const changeRange = (next: DashboardRangeKey) => {
setRange(next);
resetPages();
};
const changeScope = (next: DashboardScope) => {
setScope(next);
resetPages();
};
const resetFilters = () => {
setRange(DEFAULT_DASHBOARD_RANGE);
setScope(DASHBOARD_SCOPE_ALL);
resetPages();
};
// why: A Dispatcher's forced own-WOs scope never triggers the reset affordance (only an active date
// range can, for that role); a Scheduler/Admin's non-All scope does.
const scopeIsFiltered = canViewAll && scope.mode !== "all";
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
return {
canViewAll,
currentUserId: user?.id,
range,
scope,
dispatcherId,
workloadPage,
performancePage,
showReset,
setWorkloadPage,
setPerformancePage,
changeRange,
changeScope,
resetFilters,
};
}

View file

@ -0,0 +1,64 @@
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No active vendor has any work orders yet.";
interface VendorInsightsCardProps {
rows: VendorInsightRow[];
isLoading?: boolean;
error?: unknown;
}
export function VendorInsightsCard({ rows, isLoading = false, error }: VendorInsightsCardProps) {
return (
<DashboardCard
title="Vendor Insights"
caption="All-time, company-wide"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Vendor Insights">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell align="right" className="font-semibold">
Completion %
</TableCell>
<TableCell align="right" className="font-semibold">
Reschedule %
</TableCell>
<TableCell align="right" className="font-semibold">
Avg days
</TableCell>
<TableCell align="right" className="font-semibold">
Jobs
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.vendorId || row.vendorName}>
<TableCell>
<Text variant="description">{row.vendorName}</Text>
</TableCell>
<TableCell align="right">{`${row.completionRate.toLocaleString()}%`}</TableCell>
<TableCell align="right">{`${row.rescheduleRate.toLocaleString()}%`}</TableCell>
<TableCell align="right">
{row.averageResolutionDays == null
? "—"
: row.averageResolutionDays.toLocaleString()}
</TableCell>
<TableCell align="right">{row.totalJobs.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DashboardCard>
);
}

View file

@ -0,0 +1,47 @@
import { useNavigate } from "react-router";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardBreakdownRow } from "@/domain/dashboard/types/dashboard-stats";
import {
workOrderDrilldownUrl,
workOrderTypeDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardBarList } from "./dashboard-bar-list";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No work orders match the current filters.";
interface WorkOrderBreakdownCardProps {
breakdown: DashboardBreakdownRow[];
range: DashboardRangeKey;
isLoading?: boolean;
error?: unknown;
}
export function WorkOrderBreakdownCard({
breakdown,
range,
isLoading = false,
error,
}: WorkOrderBreakdownCardProps) {
const navigate = useNavigate();
return (
<DashboardCard
title="Work Order Breakdown"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && breakdown.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Work Order Breakdown"
rows={breakdown.map((row) => ({ id: row.status, label: row.status, count: row.count }))}
isSelectable={(row) => workOrderTypeDrilldownSearch(row.id, range) !== null}
onSelectRow={(row) => {
const search = workOrderTypeDrilldownSearch(row.id, range);
if (search) navigate(workOrderDrilldownUrl(search));
}}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,56 @@
import { useNavigate } from "react-router";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import {
UNMAPPED_REGION_LABEL,
type RegionWorkOrdersRow,
} from "@/domain/dashboard/types/dashboard-regions";
import {
workOrderDrilldownUrl,
workOrderRegionDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardBarList } from "./dashboard-bar-list";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No work orders match the current filters.";
interface WorkOrdersByRegionCardProps {
rows: RegionWorkOrdersRow[];
range: DashboardRangeKey;
isLoading?: boolean;
error?: unknown;
}
export function WorkOrdersByRegionCard({
rows,
range,
isLoading = false,
error,
}: WorkOrdersByRegionCardProps) {
const navigate = useNavigate();
// why: SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet confirmed with the client
// ("do not implement until confirmed"), so only the four canonical zones are shown.
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
return (
<DashboardCard
title="Work Orders by Region"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && zones.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Work Orders by Region"
rows={zones.map((row) => ({
id: row.region,
label: row.region,
count: row.workOrderCount,
}))}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range)))
}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,133 @@
import { useNavigate } from "react-router";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
import { useDashboardScope } from "@/app/(protected)/dashboard/_components/use-dashboard-scope";
import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card";
import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card";
import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card";
import { useDashboardPerformance } from "@/domain/dashboard/use-cases/use-dashboard-performance";
import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard-regions";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
export default function DashboardPage() {
const navigate = useNavigate();
const scope = useDashboardScope();
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
const dispatcherUsers = useWorkOrderUsers();
const stats = useDashboardStats(range, dispatcherId);
const workload = useDashboardWorkload(range, workloadPage, dispatcherId, canViewAll);
const performance = useDashboardPerformance(range, performancePage, dispatcherId, canViewAll);
const regions = useDashboardRegions(range, dispatcherId);
const vendors = useDashboardVendorInsights();
const isRefreshing =
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
!stats.isLoading;
return (
<Box className="flex flex-col gap-4 p-4">
<PageHeader
title="Dashboard"
actions={
<DashboardHeaderActions
isRefreshing={isRefreshing}
showReset={scope.showReset}
onResetFilters={scope.resetFilters}
canViewAll={canViewAll}
scope={scope.scope}
onScopeChange={scope.changeScope}
dispatcherUsers={dispatcherUsers.data ?? []}
dispatcherUsersLoading={dispatcherUsers.isLoading}
currentUserId={scope.currentUserId}
range={range}
onRangeChange={scope.changeRange}
/>
}
/>
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats.data?.scheduledTomorrow}
loading={stats.isLoading}
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats.data?.pendingUplifts}
loading={stats.isLoading}
active={(stats.data?.pendingUplifts ?? 0) > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats.data?.avetaPending}
loading={stats.isLoading}
active={(stats.data?.avetaPending ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
</Box>
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkOrderBreakdownCard
breakdown={stats.data?.breakdown ?? []}
range={range}
isLoading={stats.isLoading}
error={stats.error}
/>
<StatusDistributionCard
rows={stats.data?.statusDistribution ?? []}
range={range}
isLoading={stats.isLoading}
error={stats.error}
/>
<WorkOrdersByRegionCard
rows={regions.data ?? []}
range={range}
isLoading={regions.isLoading}
error={regions.error}
/>
{canViewAll && (
<DispatcherTablesSection
range={range}
workload={workload}
performance={performance}
workloadPage={workloadPage}
performancePage={performancePage}
onWorkloadPageChange={scope.setWorkloadPage}
onPerformancePageChange={scope.setPerformancePage}
/>
)}
</Box>
<DashboardTrendCard range={range} />
<VendorInsightsCard
rows={vendors.data ?? []}
isLoading={vendors.isLoading}
error={vendors.error}
/>
</Box>
);
}

View file

@ -1,240 +1,5 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
Alert,
Box,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
import DashboardPage from "@/app/(protected)/dashboard";
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
interface KpiCardProps {
title: string;
description?: string;
value?: string | number;
icon: ReactNode;
color: "success" | "primary" | "info" | "warning";
onClick: () => void;
loading?: boolean;
}
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
const colorMap = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
} as const;
return (
<Paper
variant="outlined"
onClick={onClick}
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
sx={{
borderLeftWidth: 4,
borderLeftStyle: "solid",
borderLeftColor: colorMap[color],
}}
>
<Box className="flex w-full gap-4 p-4">
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col justify-center gap-1">
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
{value !== undefined && (
<Typography variant="h4" component="p" className="font-bold leading-tight">
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{Boolean(description) && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
</Box>
</Box>
</Paper>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
icon={<AddIcon />}
color="success"
onClick={() => navigate("/workorders/new")}
/>
<KpiCard
title="Open Work Orders"
value={formatStat(stats?.open)}
icon={<FolderOpenOutlinedIcon />}
color="primary"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Not Dispatched"
value={formatStat(stats?.notDispatched)}
icon={<ScheduleOutlinedIcon />}
color="info"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Completed Orders"
value={formatStat(stats?.completed)}
icon={<CheckCircleOutlineIcon />}
color="warning"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
<TableCell align="right" className="font-semibold">
0-14 Days
</TableCell>
<TableCell align="right" className="font-semibold">
15-29 Days
</TableCell>
<TableCell align="right" className="font-semibold">
30-59 Days
</TableCell>
<TableCell align="right" className="font-semibold">
60+ Days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{PRIORITY_LEVELS.map((priority) => (
<TableRow key={priority}>
<TableCell>{priority}</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Invoice Analysis
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Count
</TableCell>
<TableCell align="right" className="font-semibold">
Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{INVOICE_STATUSES.map((status) => (
<TableRow key={status}>
<TableCell>{status}</TableCell>
<TableCell align="right">0</TableCell>
<TableCell align="right">$0.00</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
export default function HomePage() {
return <DashboardPage />;
}

View file

@ -0,0 +1,22 @@
import { Autocomplete, TextField } from "@mui/material";
import { AREA_ZONES, type AreaZone } from "@/data/areas";
interface AreaFacetProps {
value: AreaZone[];
onChange: (zones: AreaZone[]) => void;
}
/** Multi-select area facet; an empty selection means no area filtering. */
export function AreaFacet({ value, onChange }: AreaFacetProps) {
return (
<Autocomplete<AreaZone, true, false, false>
multiple
size="small"
className="min-w-[200px]"
options={AREA_ZONES}
value={value}
onChange={(_event, zones) => onChange(zones)}
renderInput={(params) => <TextField {...params} label="Area" placeholder="All areas" />}
/>
);
}

View file

@ -0,0 +1,237 @@
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
import {
useFieldArray,
type Control,
type FieldErrors,
type UseFormRegister,
} from "react-hook-form";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
Box,
Button,
Chip,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type {
LocationContactFormValues,
LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
interface ContactRowProps {
canRemove: boolean;
dragIndex: number | null;
errors: FieldErrors<LocationContactFormValues>;
fieldId: string;
index: number;
isLast: boolean;
register: UseFormRegister<LocationFormValues>;
onDragEnd: () => void;
onDragStart: (index: number) => void;
onDrop: (index: number) => void;
onRemove: (index: number) => void;
onKeyboardMove: (from: number, to: number) => void;
}
function ContactRow({
canRemove,
dragIndex,
errors,
fieldId,
index,
isLast,
register,
onDragEnd,
onDragStart,
onDrop,
onRemove,
onKeyboardMove,
}: ContactRowProps) {
const handleRef = useRef<HTMLSpanElement | null>(null);
const isDragging = dragIndex === index;
const isDropTarget = dragIndex !== null && dragIndex !== index;
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
if (event.key === "ArrowUp" && index > 0) {
event.preventDefault();
onKeyboardMove(index, index - 1);
}
if (event.key === "ArrowDown" && !isLast) {
event.preventDefault();
onKeyboardMove(index, index + 1);
}
};
return (
<Paper
variant="outlined"
component="div"
onDragOver={(event) => {
if (dragIndex !== null) {
event.preventDefault();
}
}}
onDrop={(event) => {
event.preventDefault();
onDrop(index);
}}
className="transition-opacity"
sx={{ opacity: isDragging ? 0.5 : 1, borderStyle: isDropTarget ? "dashed" : "solid" }}
>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ alignItems: "center", p: 1.5 }}
>
<Tooltip title="Drag to reorder. Use arrow keys to move up or down.">
<Box
component="span"
ref={handleRef}
role="button"
tabIndex={0}
aria-label={`Reorder contact ${index + 1}. Use arrow up or down to move.`}
draggable
data-field-id={fieldId}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(index));
onDragStart(index);
}}
onDragEnd={onDragEnd}
onKeyDown={handleKeyDown}
sx={{ cursor: "grab", display: "inline-flex", color: "text.secondary" }}
>
<DragIndicatorIcon fontSize="small" />
</Box>
</Tooltip>
{index === 0 && <Chip label="Main" size="small" color="primary" variant="outlined" />}
<TextField
{...register(`contacts.${index}.name` as const)}
label={index === 0 ? "Contact Name (Main)" : "Contact Name"}
required
error={Boolean(errors?.name)}
helperText={errors?.name?.message}
fullWidth
size="small"
slotProps={{ htmlInput: { maxLength: 100 } }}
/>
<TextField
{...register(`contacts.${index}.phone` as const)}
label="Phone"
required
error={Boolean(errors?.phone)}
helperText={errors?.phone?.message}
fullWidth
size="small"
slotProps={{ htmlInput: { maxLength: 20 } }}
/>
<Tooltip title={canRemove ? "Remove" : "At least one contact is required"}>
<span>
<IconButton
size="small"
aria-label={`Remove contact ${index + 1}`}
disabled={!canRemove}
onClick={() => onRemove(index)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Paper>
);
}
interface LocationContactsFieldArrayProps {
control: Control<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
export function LocationContactsFieldArray({
control,
errors,
register,
}: LocationContactsFieldArrayProps) {
const { fields, append, remove, move } = useFieldArray({
control,
name: "contacts",
keyName: "fieldKey",
});
const [dragIndex, setDragIndex] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const focusHandle = useCallback((fieldId: string) => {
requestAnimationFrame(() => {
const handle = rootRef.current?.querySelector<HTMLElement>(`[data-field-id="${fieldId}"]`);
handle?.focus();
});
}, []);
const handleKeyboardMove = useCallback(
(from: number, to: number) => {
if (to < 0 || to >= fields.length) {
return;
}
const fieldId = fields[from]?.fieldKey;
move(from, to);
if (fieldId) {
focusHandle(fieldId);
}
},
[fields, move, focusHandle],
);
const handleDrop = useCallback(
(index: number) => {
if (dragIndex !== null && dragIndex !== index) {
move(dragIndex, index);
}
setDragIndex(null);
},
[dragIndex, move],
);
return (
<Box ref={rootRef}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Contacts
</Typography>
<Button
size="small"
variant="text"
startIcon={<AddIcon fontSize="small" />}
onClick={() => append({ name: "", phone: "" })}
>
Add Contact
</Button>
</Stack>
<Stack spacing={1.5} className="mt-2">
{fields.map((field, index) => (
<ContactRow
key={field.fieldKey}
canRemove={fields.length > 1}
dragIndex={dragIndex}
errors={errors.contacts?.[index] ?? {}}
fieldId={field.fieldKey}
index={index}
isLast={index === fields.length - 1}
register={register}
onDragEnd={() => setDragIndex(null)}
onDragStart={setDragIndex}
onDrop={handleDrop}
onRemove={remove}
onKeyboardMove={handleKeyboardMove}
/>
))}
</Stack>
</Box>
);
}

View file

@ -0,0 +1,42 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
interface LocationDeleteDialogProps {
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
target: LocationListItem | null;
}
export function LocationDeleteDialog({
isPending,
onClose,
onConfirm,
target,
}: LocationDeleteDialogProps) {
return (
<Dialog open={Boolean(target)} onClose={onClose}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,143 @@
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
import {
Autocomplete,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { LocationContactsFieldArray } from "./location-contacts-field-array";
import { StateSelect } from "./state-select";
interface SharedFieldsProps {
control: Control<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
interface LocationDetailsFieldsProps extends SharedFieldsProps {
accountOptions: AccountListItem[];
isLoadingAccounts: boolean;
onAccountSearchChange: (value: string) => void;
}
export function LocationDetailsFields({
accountOptions,
control,
errors,
isLoadingAccounts,
onAccountSearchChange,
register,
}: LocationDetailsFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
onAccountSearchChange(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
);
}
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
);
}

View file

@ -1,34 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { normalizeStateCode } from "@/data/areas";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { useDebounce } from "@/hooks/use-debounce";
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
export default function LocationFormPage() {
const { id } = useParams();
@ -67,10 +53,12 @@ export default function LocationFormPage() {
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: (location.state as LocationFormValues["state"]) || "IL",
state: normalizeStateCode(location.state) ?? "",
zipCode: location.zipCode ?? "",
phone: location.phone ?? "",
contact: location.contact ?? "",
contacts:
location.contacts && location.contacts.length > 0
? location.contacts
: [{ name: location.contact ?? "", phone: location.phone ?? "" }],
contactEmail: location.contactEmail ?? "",
status: (location.status as LocationFormValues["status"]) || "Active",
});
@ -121,141 +109,15 @@ export default function LocationFormPage() {
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) =>
String(option.id) === String(value.id)
}
value={
accountOptions.find((account) => String(account.id) === field.value) ?? null
}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setAccountSearch(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<Controller
name="state"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.state)}>
<InputLabel id="state-label">State</InputLabel>
<Select {...field} labelId="state-label" label="State" value={field.value}>
{LOCATION_STATE_OPTIONS.map((state) => (
<MenuItem key={state} value={state}>
{state}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("phone")}
label="Phone"
required
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
<TextField
{...register("contact")}
label="Contact Person"
required
error={Boolean(errors.contact)}
helperText={errors.contact?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
<LocationDetailsFields
accountOptions={accountOptions}
control={control}
errors={errors}
isLoadingAccounts={isLoadingAccounts}
onAccountSearchChange={setAccountSearch}
register={register}
/>
<LocationContactFields control={control} errors={errors} register={register} />
<Stack
direction="row"

View file

@ -0,0 +1,46 @@
import { Chip, Stack, Tooltip, Typography } from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
/** Main contact name with a `+N` chip; hover/focus lists the remaining contact names. */
export function LocationsContactsCell({ row }: { row: LocationListItem }) {
const contacts = row.contacts ?? [];
const mainName = row.contact || contacts[0]?.name || "";
const remainingNames = contacts
.slice(1)
.map((contact) => contact.name.trim())
.filter((name) => name !== "");
if (mainName === "") {
return (
<Typography variant="body2" component="span">
—
</Typography>
);
}
if (remainingNames.length === 0) {
return (
<Typography variant="body2" component="span">
{mainName}
</Typography>
);
}
const remainingLabel = remainingNames.join(", ");
return (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<Typography variant="body2" component="span">
{mainName}
</Typography>
<Tooltip title={remainingLabel}>
<Chip
label={`+${remainingNames.length}`}
size="small"
tabIndex={0}
aria-label={`${remainingNames.length} more contacts: ${remainingLabel}`}
/>
</Tooltip>
</Stack>
);
}

View file

@ -0,0 +1,105 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { LocationsContactsCell } from "./locations-contacts-cell";
import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onEdit: (row: LocationListItem) => void;
rows: LocationListItem[];
}
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onEdit(row)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>
<StateAreaCell state={row.state} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => onDelete(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,35 @@
import { Stack, Typography } from "@mui/material";
import { getAreaForState, normalizeStateCode } from "@/data/areas";
/** State abbreviation with the derived area as secondary text. */
export function StateAreaCell({ state }: { state?: string | null }) {
const code = normalizeStateCode(state);
const area = code === null ? null : getAreaForState(code);
if (code === null) {
return (
<Typography variant="body2" component="span">
{state?.trim() || "—"}
</Typography>
);
}
return (
<Stack spacing={0}>
<Typography variant="body2" component="span">
{code}
</Typography>
{area !== null && (
<Typography
variant="caption"
component="span"
sx={{
color: "text.secondary",
}}
>
{area}
</Typography>
)}
</Stack>
);
}

View file

@ -0,0 +1,49 @@
import { Controller, type Control, type FieldError } from "react-hook-form";
import { Autocomplete, TextField } from "@mui/material";
import {
US_STATE_CODES,
formatStateOptionLabel,
getAreaForState,
type StateCode,
} from "@/data/areas";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
interface StateSelectProps {
control: Control<LocationFormValues>;
error?: FieldError;
}
/** Searchable, non-free-text state picker with derived area helper text. */
export function StateSelect({ control, error }: StateSelectProps) {
return (
<Controller
name="state"
control={control}
render={({ field }) => {
const selected: StateCode | null =
US_STATE_CODES.find((code) => code === field.value) ?? null;
const area = selected === null ? null : getAreaForState(selected);
const helperText = error?.message ?? (area === null ? undefined : `Area: ${area}`);
return (
<Autocomplete<StateCode>
options={US_STATE_CODES}
value={selected}
onChange={(_event, option) => field.onChange(option ?? "")}
onBlur={field.onBlur}
getOptionLabel={formatStateOptionLabel}
renderInput={(params) => (
<TextField
{...params}
label="State"
required
error={Boolean(error)}
helperText={helperText}
/>
)}
/>
);
}}
/>
);
}

View file

@ -1,53 +1,45 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
import { useDebounce } from "@/hooks/use-debounce";
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
import { AreaFacet } from "./_components/area-facet";
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
import { LocationsTable } from "./_components/locations-table";
export default function LocationsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteLocation = useDeleteLocation();
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
states: stateCodes.length > 0 ? stateCodes : undefined,
}),
[debouncedSearch, page, pageSize],
[debouncedSearch, page, pageSize, stateCodes],
);
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
@ -103,6 +95,13 @@ export default function LocationsListPage() {
alignItems: "center",
}}
>
<AreaFacet
value={selectedAreas}
onChange={(zones) => {
setSelectedAreas(zones);
setPage(0);
}}
/>
<TextField
size="small"
value={search}
@ -123,86 +122,12 @@ export default function LocationsListPage() {
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No locations found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>{row.state || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.contact || "—"}</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit location"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<LocationsTable
isLoading={isLoading}
rows={rows}
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
onDelete={setDeleteTarget}
/>
<TablePagination
component="div"
count={totalCount}
@ -215,28 +140,12 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteLocation.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteLocation.isPending}
>
{deleteLocation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
</Box>
);
}

View file

@ -1,104 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Link } from "react-router";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { Box } from "@mui/material";
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
import { PageHeader } from "@/components/ui/page-header";
export default function NotificationsPage() {
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "notifications"],
queryFn: vendorOperationsApi.notifications,
refetchInterval: 30_000,
});
const groups = data.reduce((result, item) => {
const items = result.get(item.group) ?? [];
items.push(item);
result.set(item.group, items);
return result;
}, new Map<string, typeof data>());
const center = useNotificationCenter();
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Items requiring attention
</Typography>
<Typography color="text.secondary">
Medium-severity vendor reminders refresh automatically. They do not create banners or
toasts.
</Typography>
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="Notifications"
actions={
<div className="flex items-center gap-3">
<NotificationFeedActions center={center} />
</div>
}
/>
<Box className="max-w-3xl px-5 pb-5">
<NotificationFeedList center={center} />
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Paper variant="outlined" className="p-6">
<Typography>No vendor alerts require attention.</Typography>
</Paper>
)}
{[...groups.entries()].map(([group, items]) => (
<Paper key={group} variant="outlined" className="p-4">
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
<Typography variant="h6">{group}</Typography>
<Chip label={items.length} size="small" />
</Stack>
<Stack spacing={2}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" className="p-3">
<Stack
direction={{ xs: "column", md: "row" }}
sx={{ gap: 2, justifyContent: "space-between" }}
>
<Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip label={item.severity} color="warning" size="small" />
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
</Stack>
<Typography color="text.secondary" className="mt-1">
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt != null && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action != null && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>
)}
{item.actions?.map((action) => (
<Button
key={action.href}
component={Link}
to={action.href}
variant="outlined"
>
{action.label}
</Button>
))}
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Paper>
))}
</Box>
);
}

View file

@ -0,0 +1,379 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Drawer,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
import {
SERVICE_WORK_ORDER_TYPES,
type CompletionDocTemplateOption,
type Service,
type ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
const ICONS = [
{ key: "wrench", label: "Wrench", Icon: Wrench },
{ key: "droplets", label: "Droplets", Icon: Droplets },
{ key: "zap", label: "Zap", Icon: Zap },
{ key: "hammer", label: "Hammer", Icon: Hammer },
{ key: "settings", label: "Settings", Icon: Settings2 },
] as const;
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
const Icon = icon.Icon;
return <Icon size={size} aria-hidden="true" />;
}
type DetailsProps = {
service: Service | null;
open: boolean;
canManage: boolean;
canDeactivate: boolean;
onClose: () => void;
onEdit: (service: Service) => void;
onDeactivate: (service: Service) => void;
};
export function ServiceDetailsDrawer({
service,
open,
canManage,
canDeactivate,
onClose,
onEdit,
onDeactivate,
}: DetailsProps) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: string[];
templates: CompletionDocTemplateOption[];
canChangeActiveState: boolean;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
onToggleType: (type: ServiceWorkOrderType) => void;
};
export function ServiceEditorDrawer({
mode,
form,
formError,
trades,
templates,
canChangeActiveState,
isSaving,
onClose,
onSave,
onUpdate,
onToggleType,
}: EditorProps) {
return (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<ServiceEditorFields
mode={mode}
form={form}
trades={trades}
templates={templates}
canChangeActiveState={canChangeActiveState}
onUpdate={onUpdate}
onToggleType={onToggleType}
/>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
type EditorFieldsProps = Pick<
EditorProps,
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
>;
function ServiceEditorFields({
mode,
form,
trades,
templates,
canChangeActiveState,
onUpdate,
onToggleType,
}: EditorFieldsProps) {
return (
<>
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
{mode === "edit" && canChangeActiveState && (
<FormControlLabel
control={
<Switch
checked={form.isActive ?? true}
onChange={(event) => onUpdate("isActive", event.target.checked)}
/>
}
label="Active"
/>
)}
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
getOptionLabel={(template) => template.name}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
renderInput={(params) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,279 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
type ServiceStatus,
} from "@/domain/services/use-cases/use-services-registry-controller";
import type { Service } from "@/domain/services/types/service";
import {
DeactivateServiceDialog,
ServiceDetailsDrawer,
ServiceEditorDrawer,
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
return (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search service or trade..."
className="flex-1"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
status={controller.status}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
canChangeActiveState={controller.canDeactivate}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function ServicesTable({
services,
onSelect,
}: {
services: Service[];
onSelect: (service: Service) => void;
}) {
return (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{services.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => onSelect(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
function EmptyServices({
search,
status,
onAdd,
}: {
search: boolean;
status: ServiceStatus;
onAdd?: () => void;
}) {
return (
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

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