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.
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
|
|
@ -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/**"],
|
||||
|
|
|
|||
168
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
148
.github/workflows/deploy-staging.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
299
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
43
.github/workflows/terraform-isolation.yaml
vendored
|
|
@ -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
|
|
@ -49,3 +49,6 @@ seed-data.sql
|
|||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# local scratch
|
||||
/tmp/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
|
||||

|
||||

|
||||

|
||||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 191 KiB |
256
e2e/dashboard/dashboard.visual.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
85
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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 }) => {
|
||||
|
|
|
|||
110
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -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." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
233
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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 }) => {
|
||||
|
|
|
|||
15
e2e/vendors/vendors.visual.spec.ts
vendored
|
|
@ -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 }) => {
|
||||
|
|
|
|||
185
e2e/work-orders/completion-uploads-mobile.spec.ts
Normal 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}`));
|
||||
});
|
||||
});
|
||||
80
e2e/work-orders/due-date-schedule-floor.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
265
e2e/work-orders/inline-technician.spec.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Technicians registered inline from work-order entry points
|
||||
// become real vendor records only on save, through the additive vendor-company roster
|
||||
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-inline-technician-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 41,
|
||||
companyName: "Vinewood LLC",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
const ROSTER = {
|
||||
companyId: 7,
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
name: "Vinewood LLC",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
notes: "",
|
||||
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
|
||||
};
|
||||
|
||||
const BOARD_ROW = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
workOrderType: 2,
|
||||
siteCode: "STL-01",
|
||||
locationName: "St. Louis HQ",
|
||||
locationId: 10,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
vendorId: 41,
|
||||
vendorName: "Vinewood LLC",
|
||||
techName: "Adam Whyte",
|
||||
title: "Quarterly HVAC PM",
|
||||
rowVersion: "rv-1",
|
||||
};
|
||||
|
||||
type Captured = { method: string; pathname: string; body: unknown };
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function bodyOf(request: Request): unknown {
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
|
||||
const added = (body.addTechnicians ?? []).map((technician, index) => ({
|
||||
...technician,
|
||||
id: 42 + index,
|
||||
}));
|
||||
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
|
||||
}
|
||||
|
||||
async function mockApi(page: Page, writes: Captured[]) {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) return route.fallback();
|
||||
const method = request.method();
|
||||
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
|
||||
|
||||
if (pathname.includes("/workorders/board/search")) {
|
||||
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "GET") {
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [BOARD_ROW],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "POST") {
|
||||
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
|
||||
}
|
||||
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
|
||||
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster" && method === "GET") {
|
||||
return fulfillJson(route, ROSTER);
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
|
||||
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
|
||||
}
|
||||
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
|
||||
return fulfillJson(route, { data: VENDORS });
|
||||
}
|
||||
if (pathname.includes("/locations/sites")) {
|
||||
return fulfillJson(route, {
|
||||
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/locations/10") {
|
||||
return fulfillJson(route, {
|
||||
data: {
|
||||
id: 10,
|
||||
name: "St. Louis HQ",
|
||||
siteCode: "STL-01",
|
||||
contact: "Pat Site",
|
||||
phone: "3145550111",
|
||||
},
|
||||
});
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
function vendorWrites(writes: Captured[]) {
|
||||
return writes.filter(
|
||||
(write) =>
|
||||
write.pathname.startsWith("/api/vendors") ||
|
||||
write.pathname.startsWith("/api/vendor-company-roster"),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWizardVendorStep(page: Page) {
|
||||
await page.goto("/workorders");
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select company/i }).click();
|
||||
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
|
||||
await page.getByRole("button", { name: "+ Add technician" }).click();
|
||||
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
|
||||
await page.getByLabel("New Technician 1 phone").fill("3145550199");
|
||||
}
|
||||
|
||||
test.describe("Inline technician registration", () => {
|
||||
test("wizard persists the technician under the company on create and assigns it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
// Staging a technician is local only.
|
||||
expect(vendorWrites(writes)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "Create work order" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
|
||||
.toBeTruthy();
|
||||
expect(vendorWrites(writes)).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
pathname: "/api/vendor-company-roster/7",
|
||||
body: {
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
addTechnicians: [
|
||||
{
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-0199",
|
||||
email: "",
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const create = writes.find((write) => write.pathname === "/api/workorders/board");
|
||||
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
|
||||
});
|
||||
|
||||
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
|
||||
|
||||
// Reopening starts clean: the staged technician was discarded, not persisted.
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await page.goto("/workorders");
|
||||
|
||||
await page
|
||||
.locator("#wo-row-1")
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
|
||||
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
|
||||
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
|
||||
await dialog.getByRole("button", { name: /^add$/i }).click();
|
||||
await dialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
|
||||
.toBeTruthy();
|
||||
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
|
||||
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
|
||||
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
|
||||
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
|
||||
expect(JSON.stringify(boardPatch?.body)).toContain("42");
|
||||
});
|
||||
});
|
||||
174
e2e/work-orders/wizard-vendor-company-notes.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
121
e2e/work-orders/wo-number-sh-placeholder.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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'/);
|
||||
});
|
||||
78
scripts/check_app_terraform_isolation.py
Normal 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())
|
||||
|
|
@ -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."
|
||||
29
scripts/g13-live-isolation.mjs
Normal 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];
|
||||
}
|
||||
|
|
@ -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")})`);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
73
scripts/test-g13-live-isolation.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
scripts/test_check_app_terraform_isolation.py
Normal 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()
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
52
src/app/(protected)/dashboard/_components/dashboard-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
src/app/(protected)/dashboard/_components/dashboard-scope.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
133
src/app/(protected)/dashboard/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
22
src/app/(protected)/locations/_components/area-facet.tsx
Normal 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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{target?.name}"? 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
105
src/app/(protected)/locations/_components/locations-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
49
src/app/(protected)/locations/_components/state-select.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||