mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
71 commits
111eb55659
...
8ec91f0dac
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec91f0dac | |||
| 7ab6fa30e7 | |||
| 0bc7e22884 | |||
| b71c0ad87b | |||
| 08da408a13 | |||
| 87e79072ad | |||
| 82361e14b5 | |||
| 78398482cf | |||
|
|
73346371e5 | ||
|
|
fdd97c24f9 | ||
|
|
b7d531f554 | ||
|
|
1fdecdd45d | ||
|
|
8e0f7617aa | ||
|
|
117f7949f5 | ||
|
|
15de3fca0a | ||
|
|
11743caa61 | ||
|
|
a7d359ffc4 | ||
|
|
eab83f6562 | ||
|
|
54671baf5c | ||
|
|
ef2ba9da1f | ||
|
|
a54fcaae9f | ||
|
|
252c0d0856 | ||
|
|
ba69706fcf | ||
|
|
3bb7acde2d | ||
|
|
c71d9482ec | ||
|
|
dee1578efe | ||
|
|
e761e0dae5 | ||
|
|
1607bac8ad | ||
|
|
47f78fec89 | ||
|
|
edf4a51d4c | ||
|
|
599e38aae3 | ||
|
|
4006145ef8 | ||
|
|
807d8ac1a8 | ||
|
|
6c765e8b42 | ||
|
|
747515870e | ||
|
|
74e5c58e5d | ||
|
|
793230f0c2 | ||
|
|
dc9cc07a73 | ||
|
|
ccdad96170 | ||
|
|
a766dc9c50 | ||
|
|
f51f6bd493 | ||
|
|
418eb3749d | ||
|
|
7cc7cc19d0 | ||
|
|
b0c58958ae | ||
|
|
3ed8600beb | ||
|
|
d6163e7eb6 | ||
|
|
fc07371f0d | ||
|
|
b8349783a7 | ||
|
|
e3ffa5a94b | ||
|
|
9f9aa5c824 | ||
|
|
fa866c5ea7 | ||
|
|
207199559f | ||
|
|
27ddeb2903 | ||
|
|
dc313dc50a | ||
|
|
a24002bc24 | ||
|
|
370b2a68ad | ||
|
|
3a8f41a444 | ||
|
|
c1863c4274 | ||
|
|
9fc67516ca | ||
|
|
ddd83ffec1 | ||
|
|
c2d1add79c | ||
|
|
9cc9bc4dfb | ||
|
|
dc178340de | ||
|
|
bc1e7a1fa2 | ||
|
|
08e00c213d | ||
|
|
c70587941c | ||
|
|
426889427c | ||
|
|
7e1dcb7a36 | ||
|
|
0ee2879234 | ||
|
|
85ea33b0ad | ||
|
|
72417bbb77 |
183 changed files with 8860 additions and 2793 deletions
|
|
@ -8,5 +8,13 @@
|
|||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
|
||||
# Leave blank to disable telemetry for that build.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# Sentry environment label (development, staging, production). Blank falls back
|
||||
# to the Vite build mode.
|
||||
#VITE_SENTRY_ENVIRONMENT=
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@
|
|||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
|
||||
# Public browser configuration; the DSN is public by design and baked into the
|
||||
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
|
||||
# and future prod jobs override them per environment.
|
||||
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
|
|
|||
2
.github/renovate.json
vendored
2
.github/renovate.json
vendored
|
|
@ -18,7 +18,7 @@
|
|||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": ["Group non-major Terraform updates"],
|
||||
"description": ["Group non-major Terraform provider updates"],
|
||||
"matchManagers": ["terraform"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "terraform minor and patch"
|
||||
|
|
|
|||
37
.github/workflows/ci.yaml
vendored
37
.github/workflows/ci.yaml
vendored
|
|
@ -23,9 +23,11 @@ jobs:
|
|||
# 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). If the reusable workflow is later confirmed to run
|
||||
# every gate, this job can be slimmed to `npm run governance`.
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard
|
||||
# tests, Terraform isolation gate tests, CDK build/test/synth). If the
|
||||
# reusable workflow is later confirmed to run every gate, this job can be
|
||||
# slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
|
|
@ -54,9 +56,12 @@ jobs:
|
|||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
|
||||
# the remote run will see.
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
@ -66,6 +71,30 @@ jobs:
|
|||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes `terraform/**` 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; it is
|
||||
# read when the job runs, so re-run this workflow after labeling.
|
||||
name: Terraform and application changes are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
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}"
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
121
.github/workflows/deploy-staging.yml
vendored
121
.github/workflows/deploy-staging.yml
vendored
|
|
@ -1,6 +1,17 @@
|
|||
name: Deploy staging content
|
||||
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:
|
||||
|
|
@ -13,7 +24,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Publish content to staging
|
||||
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'
|
||||
|
|
@ -21,23 +32,27 @@ jobs:
|
|||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
EXPECTED_API_URL: https://api.staging.seahaven.com/api
|
||||
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.tf-poc.seahaven.com/api
|
||||
SITE_URL: https://staging.seahaven.com
|
||||
API_SMOKE_URL: https://api.staging.seahaven.com/swagger/v1/swagger.json
|
||||
SITE_BUCKET: seahaven-shoc-frontend-staging
|
||||
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-staging
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2JDVEZ6EGD49J
|
||||
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||
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: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
- 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"
|
||||
|
|
@ -47,7 +62,7 @@ jobs:
|
|||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
- name: Assume staging deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
|
|
@ -55,5 +70,79 @@ jobs:
|
|||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Build, publish, and verify SPA
|
||||
# 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."
|
||||
|
|
|
|||
53
.github/workflows/deploy-tf-poc.yml
vendored
53
.github/workflows/deploy-tf-poc.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
name: Deploy tf-poc content
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-tf-poc
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Publish content to tf-poc
|
||||
if: github.ref == 'refs/heads/feature/terraform-cd-poc'
|
||||
runs-on: ubuntu-latest
|
||||
environment: tf-poc
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_API_URL: https://api.tf-poc.seahaven.com/api
|
||||
EXPECTED_API_URL: https://api.tf-poc.seahaven.com/api
|
||||
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.staging.seahaven.com/api
|
||||
SITE_URL: https://frontend-tf-poc.seahaven.com
|
||||
SITE_BUCKET: seahaven-shoc-frontend-tf-poc
|
||||
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-tf-poc
|
||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
||||
API_SMOKE_URL: https://api.tf-poc.seahaven.com/swagger/v1/swagger.json
|
||||
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Quality gates
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
- name: Assume tf-poc 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-tf-poc
|
||||
aws-region: us-east-1
|
||||
- name: Build, publish, and verify SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
88
.github/workflows/deploy.yml
vendored
88
.github/workflows/deploy.yml
vendored
|
|
@ -1,5 +1,19 @@
|
|||
name: Deploy dev content
|
||||
|
||||
# Manual dev content deployment during the Terraform adoption (SH-300).
|
||||
#
|
||||
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are
|
||||
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are
|
||||
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
|
||||
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
|
||||
# completes. Automatic push-to-`dev` releases return with the Terraform
|
||||
# content-CD change, gated on a repository variable.
|
||||
#
|
||||
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
|
||||
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
|
||||
# deploy role. The bucket and distribution are pinned here so a content deploy
|
||||
# keeps working after CloudFormation relinquishes the stack outputs.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
|
|
@ -14,35 +28,81 @@ concurrency:
|
|||
jobs:
|
||||
deploy:
|
||||
name: Publish content to dev
|
||||
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
|
||||
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
EXPECTED_API_URL: https://api.dev.seahaven.com/api
|
||||
FORBIDDEN_API_URLS: https://api.staging.seahaven.com/api,https://api.tf-poc.seahaven.com/api
|
||||
SITE_URL: https://dev.seahaven.com
|
||||
API_SMOKE_URL: https://api.dev.seahaven.com/swagger/v1/swagger.json
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
DEPLOY_RELEASE_ID: ${{ github.sha }}
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Quality gates
|
||||
- name: Set up Terraform
|
||||
# Required by `npm run verify` (governance runs terraform fmt/validate).
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
|
||||
- name: Assume dev 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-dev
|
||||
aws-region: us-east-1
|
||||
- name: Build, publish, and verify SPA
|
||||
|
||||
# Builds with the dev values committed in .env.production (VITE_API_URL,
|
||||
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
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
|
||||
SITE_URL="https://dev.seahaven.com"
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
echo "Built assets reference the dev API URL."
|
||||
|
||||
# The invalidation has completed, but give edges a short window to
|
||||
# converge before calling the served index.html wrong.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
matched=false
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \
|
||||
&& cmp -s dist/index.html "${remote_dir}/index.html"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
if [[ "${matched}" != "true" ]]; then
|
||||
echo "::error::Served index.html does not match the build just published." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Served index.html matches the published build."
|
||||
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login"
|
||||
echo "Extensionless SPA route serves."
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -48,14 +48,13 @@ infra/cdk/bin/*.js
|
|||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
|
||||
# terraform
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
!*.tfvars.example
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ npm run verify
|
|||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the Terraform
|
||||
import-plan contract, Terraform formatting and validation, the web deployment
|
||||
contract, and CDK build/synth. A task is not done until this is green.
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: the Terraform import-plan checker tests, the Terraform isolation gate
|
||||
tests, Terraform formatting and validation, and the CDK build, template tests,
|
||||
and synthesis. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
|
|
@ -25,10 +26,11 @@ contract, and CDK build/synth. A task is not done until this is green.
|
|||
| 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 plan-checker contract | `npm run test:terraform-import-plan` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform formatting/validation | `npm run test:terraform` | `governance` + CI | tf-poc, dev, and staging roots |
|
||||
| Web deployment/rollback contract | `npm run test:deploy-web` | `governance` + CI | `scripts/deploy-web.sh` |
|
||||
| CDK compile and synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**` |
|
||||
| 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 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` |
|
||||
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes |
|
||||
| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -42,10 +44,15 @@ contract, and CDK build/synth. A task is not done until this is green.
|
|||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **Real import and controlled-update plans remain migration evidence** — CI
|
||||
tests the checker and validates configuration, but it cannot evaluate live
|
||||
AWS/HCP state. Each environment requires a saved `terraform show -json` plan
|
||||
and checker output before an approved apply.
|
||||
- **Terraform gates never touch live state** — `terraform init -backend=false
|
||||
-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 reads the override when it runs** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR, the gate logs the override on the run, and the
|
||||
workflow must be re-run after the label is added or removed.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
|
|
@ -54,11 +61,16 @@ contract, and CDK build/synth. A task is not done until this is green.
|
|||
- **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` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
`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. On pull requests the same workflow's
|
||||
`terraform-isolation` job fails when `terraform/**` and application code
|
||||
change together.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
|
|
|
|||
163
README.md
163
README.md
|
|
@ -13,17 +13,15 @@ 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> and <https://staging.seahaven.com>
|
||||
- **Backend APIs:** matching `api.<environment>.seahaven.com/api` endpoints,
|
||||
called directly from the browser
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||
|
||||
## Architecture
|
||||
|
||||
Static SPA hosting on AWS. CloudFront serves the built `dist/` from a private,
|
||||
versioned S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||
`VITE_API_URL`. The live stacks remain CDK/CloudFormation-owned while the
|
||||
import-first Terraform transfer is rehearsed and reviewed. See
|
||||
[`terraform/README.md`](terraform/README.md).
|
||||
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
||||
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
||||
from a private S3 bucket; 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
|
||||
|
|
@ -31,10 +29,15 @@ graph LR
|
|||
CF -->|OAC| 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[Manual GitHub deployment] -->|OIDC| ROLE[Environment deploy role]
|
||||
ROLE -->|content publish + invalidation| S3
|
||||
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|s3 sync + invalidation| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
|
||||
```
|
||||
|
||||
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
|
||||
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
|
||||
current ownership state.
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||
|
|
@ -42,17 +45,16 @@ architecture plan for the keep/discard migration matrix).
|
|||
|
||||
## AWS Resources
|
||||
|
||||
Stacks **`shoc-frontend-dev`** and **`shoc-frontend-staging`** are currently
|
||||
CDK-owned in account `396287094661`, region `us-east-1`. They are defined in
|
||||
[`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
||||
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| S3 bucket | `seahaven-shoc-frontend-{dev,staging}` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||
| CloudFront distribution | `E2CWLM1AFB964P` / `E2JDVEZ6EGD49J` | HTTPS static hosting on the matching environment domain |
|
||||
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||
| IAM role | `githubdeploy-shoc-frontend-new-{dev,staging}` | Environment-scoped GitHub OIDC content deploy role |
|
||||
| Route 53 records | A/AAAA aliases in the dev and staging hosted zones | Point each custom domain at its CloudFront distribution |
|
||||
| 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 |
|
||||
|
||||
No Lambdas, queues, or databases — this stack is static hosting only.
|
||||
|
||||
|
|
@ -60,9 +62,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager, SSM parameters, AWS access keys, or deploy-role repo secret
|
||||
are used. Content workflows assume their pinned environment role through
|
||||
GitHub OIDC.
|
||||
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
|
||||
repo secret** is:
|
||||
|
||||
| Secret | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
|
||||
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
|
|
@ -77,9 +83,9 @@ GitHub OIDC.
|
|||
build otherwise. See [`.env.example`](.env.example),
|
||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context for normal dev synthesis lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json). CDK deployment is no longer part of
|
||||
recurring content releases during the ownership transfer.
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
|
||||
`cdk deploy` with no flags.
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -96,17 +102,22 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
|||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
|
||||
`npm run test:terraform-isolation`) and installs `infra/cdk` for
|
||||
`npm run test:infra`.
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -115,7 +126,7 @@ 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/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
work: `feature/`, `bug/`, `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
|
||||
|
|
@ -124,65 +135,81 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
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.
|
||||
- Promotion flow during migration: `feature/* → dev`, then an explicitly
|
||||
approved manual dev deployment and verification on `dev.seahaven.com`.
|
||||
Staging promotion and deployment are separate approvals. No production
|
||||
environment exists yet.
|
||||
- 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 the
|
||||
**Deploy dev content** workflow while the Terraform adoption is in progress)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
## Deployment
|
||||
|
||||
CI/CD uses OIDC and stores no AWS access keys:
|
||||
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`, calls
|
||||
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)) is
|
||||
guaranteed from this repository. Conventions and gates are documented under
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the CDK template tests) is guaranteed from this
|
||||
repository. 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).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml),
|
||||
[`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml),
|
||||
and [`.github/workflows/deploy-tf-poc.yml`](.github/workflows/deploy-tf-poc.yml))
|
||||
is manual-only during migration. [`scripts/deploy-web.sh`](scripts/deploy-web.sh)
|
||||
publishes to pinned targets, verifies cache/API/routing behavior, retains two
|
||||
release manifests, and restores the prior versioned index on verification
|
||||
failure.
|
||||
- **Terraform isolation** (the `terraform-isolation` job in
|
||||
[`.github/workflows/ci.yaml`](.github/workflows/ci.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` only while the Terraform adoption is in
|
||||
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
|
||||
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation, then uploads source maps and checks the served
|
||||
`index.html` matches the build. Push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence
|
||||
and the HCP Terraform workspace `shoc-frontend-new-dev`
|
||||
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
|
||||
([`infra/cdk/README.md`](infra/cdk/README.md)).
|
||||
|
||||
The isolated rehearsal, retention mechanism, and deployment prerequisites are
|
||||
documented in [`infra/cdk/README.md`](infra/cdk/README.md). Terraform ownership,
|
||||
HCP configuration, import gates, evidence, and rollback are documented in
|
||||
[`terraform/README.md`](terraform/README.md).
|
||||
Manual content deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account):
|
||||
|
||||
Infrastructure changes and ownership transfer remain separate reviewed
|
||||
administrator actions. Content workflows never run `cdk deploy` or Terraform
|
||||
apply.
|
||||
```bash
|
||||
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
|
||||
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
|
||||
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
|
||||
directly and API calls succeed.
|
||||
- **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
|
||||
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
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
|
||||
on this repo; deploys from other branches/repos are rejected by design.
|
||||
- _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.
|
||||
- _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`.
|
||||
- **Deploy workflow is unavailable on an arbitrary ref** — each manual workflow
|
||||
checks its exact branch or protected GitHub environment before assuming AWS
|
||||
credentials.
|
||||
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
|
||||
runs CI only; publish through the **Deploy dev content** workflow. Merging a
|
||||
`terraform/**` change also queues an HCP Terraform run that a human confirms
|
||||
or discards (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||
- Dev Terraform adoption 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/)
|
||||
|
|
|
|||
26
config/sentry-release.test.ts
Normal file
26
config/sentry-release.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
|
||||
|
||||
describe("resolveSentryRelease", () => {
|
||||
it("maps the exact commit to the frontend release", () => {
|
||||
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
expect(resolveSentryRelease(sha, true)).toEqual({
|
||||
commitSha: sha.toLowerCase(),
|
||||
release: `shoc-frontend@${sha.toLowerCase()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails CI when the commit identity is missing or malformed", () => {
|
||||
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
|
||||
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
|
||||
});
|
||||
|
||||
it("labels non-CI builds without a commit as local development", () => {
|
||||
expect(resolveSentryRelease(undefined, false)).toEqual({
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
config/sentry-release.ts
Normal file
32
config/sentry-release.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const SENTRY_SERVICE_NAME = "shoc-frontend";
|
||||
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function resolveSentryRelease(
|
||||
candidate: string | undefined,
|
||||
ci: boolean,
|
||||
): {
|
||||
commitSha: string;
|
||||
release: string;
|
||||
} {
|
||||
const commitSha = candidate?.trim();
|
||||
|
||||
if (commitSha && COMMIT_SHA.test(commitSha)) {
|
||||
return {
|
||||
commitSha: commitSha.toLowerCase(),
|
||||
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ci) {
|
||||
throw new Error(
|
||||
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
};
|
||||
}
|
||||
94
docs/adr/0002-sentry-observability.md
Normal file
94
docs/adr/0002-sentry-observability.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# 0002. Browser error and transaction telemetry with Sentry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA has no centralized browser error reporting or distributed
|
||||
transaction tracing. Operational failures therefore have to be reconstructed
|
||||
from user reports and isolated backend logs. We need browser navigation and API
|
||||
request transactions to join the backend trace while preserving the existing UI,
|
||||
routing, authentication, and server-state behavior. Every emitted transaction
|
||||
must also identify its exact build, trace, transaction, operation, function, and
|
||||
opaque actor without sending credentials, request bodies, query values, or
|
||||
personal data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
|
||||
router is created, and wrap `createBrowserRouter` with Sentry's current React
|
||||
Router compatibility API. Configure the browser tracing integration with a 100%
|
||||
transaction sample rate, and propagate trace headers only to the SPA origin and
|
||||
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
||||
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
|
||||
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
|
||||
and recoverable component errors as Sentry events.
|
||||
|
||||
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
|
||||
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
|
||||
They use normalized route templates and attach `code.function`, `operation.type`,
|
||||
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
|
||||
receive the same searchable trace identifiers in `beforeSendTransaction`.
|
||||
Authenticated sessions set only the application's opaque numeric/string user ID;
|
||||
logout and session expiry clear it.
|
||||
|
||||
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
|
||||
browser configuration because every `VITE_` value is embedded in the built
|
||||
JavaScript. `.env.production` commits the public DSN of the real
|
||||
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
|
||||
or blank DSN in any other build leaves telemetry inactive without preventing
|
||||
the application from starting. Default personally identifiable information
|
||||
collection remains disabled.
|
||||
|
||||
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
|
||||
non-blank, falling back to the Vite build mode otherwise. The committed
|
||||
`.env.production` labels dev deploys `development`, the staging deploy job
|
||||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
|
||||
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
|
||||
resolve that identity. Vite creates hidden source maps, and the dev/staging
|
||||
deployment workflows upload them to the private `shoc-frontend` Sentry project
|
||||
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
|
||||
excludes `*.map`; the upload credential is never available to the browser build.
|
||||
|
||||
Before-send processors reduce request data to normalized URL plus HTTP method,
|
||||
reduce users to opaque ID only, attach native trace/transaction identifiers as
|
||||
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
|
||||
URL metadata. Root and child span attributes use the same allowlist, retaining
|
||||
function, operation, actor type, normalized route, method/status, service host,
|
||||
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
|
||||
cookies, query strings/values, free-form breadcrumb messages, usernames, email
|
||||
addresses, IP addresses, tokens, and filenames are not intentionally sent.
|
||||
`sendDefaultPii` remains disabled.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Manual error and timing calls** — rejected because they would miss route
|
||||
transitions and distributed request context, and would require every feature
|
||||
to maintain its own instrumentation.
|
||||
- **Propagate tracing headers to every request** — rejected because third-party
|
||||
requests must not receive SeaHaven trace metadata and broad propagation can
|
||||
introduce cross-origin request failures.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser navigations and API requests are recorded as connected transactions
|
||||
when the deployment supplies a valid DSN.
|
||||
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
|
||||
deploys, `staging` on the staging job), falling back to the Vite build mode
|
||||
when unset.
|
||||
- Every transaction is sampled. This meets the current requirement but increases
|
||||
event volume and should be reviewed against Sentry quota and retention after
|
||||
real traffic is measured.
|
||||
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
|
||||
`baggage` request headers; the current API CORS policy allows request headers.
|
||||
- Deployed stack traces resolve against the exact commit's private source maps
|
||||
once the scoped upload secret is present and the deployment workflow runs.
|
||||
- Rollback is isolated: remove `@sentry/react`, the initialization module and
|
||||
import, restore the unwrapped router factory, and remove the Sentry env entries.
|
||||
|
|
@ -1,136 +1,270 @@
|
|||
# Frontend infrastructure and migration rehearsal
|
||||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||
|
||||
This CDK app describes the existing Sea Haven SHOC SPA hosting and an isolated,
|
||||
production-shaped Terraform adoption rehearsal. It performs no content upload.
|
||||
Content-only deployment is handled by `scripts/deploy-web.sh`.
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
|
||||
Infrastructure deploys are administrator-run; GitHub Actions publishes content
|
||||
only.
|
||||
|
||||
## Existing environments
|
||||
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
|
||||
> `shoc-frontend-dev` is in the retain/transfer sequence described under
|
||||
> [Terraform adoption mode](#terraform-adoption-mode) and in
|
||||
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
|
||||
> `cdk deploy` against dev while that sequence is in progress. Staging is
|
||||
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
|
||||
|
||||
Normal synthesis remains unchanged when the adoption flag is off:
|
||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
||||
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
|
||||
them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
per-environment (see the note under "Adding staging / prod").
|
||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
|
||||
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
|
||||
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
|
||||
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
|
||||
retired with the adoption PR.
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
|
||||
- private, versioned S3 bucket with CDK auto-delete cleanup
|
||||
- CloudFront distribution and origin access control
|
||||
- viewer-request function that rewrites extensionless SPA routes
|
||||
- optional Route 53 A and AAAA aliases
|
||||
- GitHub Actions OIDC deploy role
|
||||
```
|
||||
infra/cdk/
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
|
||||
test/frontend-stack.test.mjs template assertions for both modes
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
|
||||
deploy.yml dev content publish (workflow_dispatch on dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
Dev remains the default context in `cdk.json`. Staging uses explicit context
|
||||
arguments. During migration, both content workflows are manual-only and use
|
||||
fixed environment configuration rather than discovering deployment targets
|
||||
from CloudFormation.
|
||||
## What the stack creates
|
||||
|
||||
## Terraform POC
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
||||
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
|
||||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
|
||||
The POC is isolated in account `396287094661`, region `us-east-1`, and is
|
||||
created only with `-c tfPoc=true` plus an explicit `tfPocPhase`. It uses three
|
||||
ownership scopes:
|
||||
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
|
||||
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
|
||||
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
|
||||
is left byte-identical on purpose so the Terraform import is a no-op; the
|
||||
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
|
||||
account resource — the stack only _imports_ it (created in step 2), so
|
||||
`cdk destroy` can't delete a resource shared by other roles.
|
||||
|
||||
1. `shoc-frontend-tf-poc-shared`: a dedicated public hosted zone for
|
||||
`frontend-tf-poc.seahaven.com`.
|
||||
2. `shoc-frontend-tf-poc-certificate`: the DNS-validated ACM certificate.
|
||||
3. `shoc-frontend-tf-poc`: the private versioned bucket, CloudFront OAC,
|
||||
distribution, SPA function, A/AAAA aliases, and GitHub OIDC content deploy
|
||||
role.
|
||||
## Terraform adoption mode
|
||||
|
||||
Fixed application values:
|
||||
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
|
||||
used only while HCP Terraform adopts the dev resources. It is off by default
|
||||
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
|
||||
both). In adoption mode the stack:
|
||||
|
||||
- bucket: `seahaven-shoc-frontend-tf-poc`
|
||||
- role: `githubdeploy-shoc-frontend-new-tf-poc`
|
||||
- GitHub environment: `tf-poc`
|
||||
- site: `https://frontend-tf-poc.seahaven.com`
|
||||
- API: `https://api.tf-poc.seahaven.com/api`
|
||||
- pins the origin ID CloudFormation generated for the live distribution
|
||||
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
|
||||
metadata-only; environments without a verified value fail synthesis
|
||||
- attaches the `seahaven-org-baseline` permissions boundary
|
||||
`shoc-frontend-new-dev-deploy-boundary` and the
|
||||
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
|
||||
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
|
||||
same exact value
|
||||
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
|
||||
transferred resources (bucket, bucket policy, distribution, OAC, SPA
|
||||
function, A and AAAA records, deploy role, inline policy) and to
|
||||
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
|
||||
Lambda and role stay unretained
|
||||
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
|
||||
default) and conditions those same resources and every output on it
|
||||
- emits `TerraformImport*` outputs carrying the exact import IDs
|
||||
|
||||
The shared stack is intentionally staged. The zone must exist and be delegated
|
||||
before ACM can validate a certificate inside it:
|
||||
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
|
||||
state the ownership phase:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
|
||||
# Phase 1, before the Terraform import: keep the resources in the stack and
|
||||
# install Retain on them. Update-only change set.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
|
||||
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
|
||||
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=false
|
||||
```
|
||||
|
||||
Both deploys must use the same reviewed SHA. Review the change set before
|
||||
confirming: Phase 1 must show no create, delete, or replace. After the
|
||||
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
|
||||
again. If the `true` deploy rolls back, inspect the stack resources and the
|
||||
live bucket before retrying; retained resources can outlive a failed update and
|
||||
must not be cleaned up automatically. Never delete the auto-delete custom
|
||||
resource while its handler can still empty the versioned bucket.
|
||||
|
||||
Local checks (`npm run test:infra` from the repo root) build the app, run the
|
||||
template assertions, and synthesize both modes.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (run by a human with admin AWS creds)
|
||||
|
||||
### 1. Authenticate to the AWS account
|
||||
|
||||
```bash
|
||||
aws configure # or: aws sso login --profile <admin>
|
||||
aws sts get-caller-identity # confirm the right account + region (us-east-1)
|
||||
```
|
||||
|
||||
### 2. Ensure the GitHub OIDC provider exists (once per account)
|
||||
|
||||
```bash
|
||||
aws iam list-open-id-connect-providers
|
||||
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
|
||||
# no longer required — AWS validates GitHub against its own trust store):
|
||||
aws iam create-open-id-connect-provider \
|
||||
--url https://token.actions.githubusercontent.com \
|
||||
--client-id-list sts.amazonaws.com
|
||||
```
|
||||
|
||||
### 3. CDK bootstrap (once per account/region)
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npm ci
|
||||
npm test
|
||||
npm run synth:tf-poc-zone
|
||||
npm run synth:tf-poc-environment
|
||||
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
|
||||
```
|
||||
|
||||
After approval, deploy only `shoc-frontend-tf-poc-shared` with
|
||||
`tfPocPhase=zone`. Its outputs provide the child name servers. Create the
|
||||
parent NS record as a separate approved change and verify public delegation.
|
||||
Only then use `tfPocPhase=environment` to deploy the separate certificate and
|
||||
site stacks. The zone stack never contains the certificate, so re-running the
|
||||
zone phase cannot remove a certificate created by the environment phase.
|
||||
Omitting `tfPocPhase` fails closed.
|
||||
### 4. Domain, cert, and API URL (already wired for dev)
|
||||
|
||||
The stacks output the hosted zone ID, certificate ARN, delegation evidence,
|
||||
workspace tag, boundary ARN, and import IDs for the bucket, bucket policy,
|
||||
distribution, OAC, SPA function, A/AAAA records, deploy role, and inline role
|
||||
policy. They also emit the generated OAC name/description, deterministic origin
|
||||
ID, and inline policy name required by the tf-poc Terraform configuration.
|
||||
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
|
||||
|
||||
## Adoption retention
|
||||
| Context key | Value |
|
||||
| --------------------------------- | ------------------------------------------------------------ |
|
||||
| `domainNames` | `dev.seahaven.com` |
|
||||
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
|
||||
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
|
||||
|
||||
`-c retainForTerraformAdoption=true` is deliberately opt-in. Keep it enabled
|
||||
from the reviewed retention deployment through CloudFormation ownership
|
||||
detachment.
|
||||
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
|
||||
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
|
||||
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
|
||||
baked into the build. Per-environment; override for staging/prod.
|
||||
|
||||
The emitted template applies both `DeletionPolicy: Retain` and
|
||||
`UpdateReplacePolicy: Retain` to:
|
||||
### 5. First deploy (locally, with admin creds)
|
||||
|
||||
- site bucket and bucket policy
|
||||
- distribution, OAC, and SPA rewrite function
|
||||
- A and AAAA records
|
||||
- GitHub deploy role and its inline policy
|
||||
- `SiteBucket/AutoDeleteObjectsCustomResource`
|
||||
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
|
||||
locally. This provisions infra + the role:
|
||||
|
||||
The bucket remains configured with `autoDeleteObjects: true`. The emitted
|
||||
bucket and its matching custom resource are both retained, so deleting the
|
||||
stack cannot invoke that custom resource to empty the versioned bucket.
|
||||
Generated provider Lambda resources, provider IAM resources, provider logs,
|
||||
and CDK metadata are intentionally excluded. Template tests enforce this exact
|
||||
boundary.
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy
|
||||
```
|
||||
|
||||
In adoption mode, the deploy role also receives:
|
||||
Note the `DeployRoleArn` output. Then publish the first content manually:
|
||||
|
||||
- tag `HcpTerraformWorkspace=shoc-frontend-new-{env}`
|
||||
- permissions boundary
|
||||
`arn:aws:iam::<account>:policy/shoc-frontend-new-{env}-deploy-boundary`
|
||||
- exact `StringEquals` OIDC subject matching; for dev this narrows the current
|
||||
no-wildcard `StringLike` subject before Terraform import
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
These changes are absent when the flag is off.
|
||||
### 6. Content deploys
|
||||
|
||||
## Content deployment safeguards
|
||||
The deploy role ARN is deterministic and pinned in
|
||||
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
|
||||
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
|
||||
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
|
||||
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
|
||||
pinned bucket and distribution, uploads source maps, and verifies the served
|
||||
`index.html` matches the build. Automatic push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
|
||||
`scripts/deploy-web.sh` requires explicit target and expectation variables:
|
||||
---
|
||||
|
||||
- `SITE_BUCKET` and matching `EXPECTED_SITE_BUCKET`
|
||||
- `CLOUDFRONT_DISTRIBUTION_ID`
|
||||
- `SITE_URL`
|
||||
- `VITE_API_URL` and matching `EXPECTED_API_URL`
|
||||
- `DEPLOY_RELEASE_ID` or `GITHUB_SHA`
|
||||
- optional comma-separated `FORBIDDEN_API_URLS`
|
||||
- optional `API_SMOKE_URL` and `API_CORS_ORIGIN`
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
The script verifies bucket versioning, builds the app, publishes immutable
|
||||
assets and a no-cache index, records a release manifest, invalidates and waits,
|
||||
then checks `/`, `/login`, an extensionless route, asset references, API URLs,
|
||||
and cache headers. Optional API preflight checks verify CORS.
|
||||
Staging lives in the same AWS account (396287094661) and deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
|
||||
`staging`:
|
||||
|
||||
If verification fails after publishing the index, the previous index version
|
||||
is restored and invalidated. Pruning starts only after successful remote
|
||||
verification. Current versions needed by the latest two release manifests are
|
||||
kept; unreferenced object versions are deleted.
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
environment subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||
`environment: staging`, so only runs in that environment can assume the role.
|
||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
||||
unchanged.
|
||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
||||
the workflow pins
|
||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
||||
the staging role, then runs `scripts/deploy-web.sh` with
|
||||
`STACK_NAME=shoc-frontend-staging`,
|
||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
||||
CloudFront invalidation to complete.
|
||||
- **Application-only role:** the recurring staging workflow can describe only
|
||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
||||
command below.
|
||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
||||
|
||||
## Manual workflows
|
||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
||||
|
||||
- `.github/workflows/deploy.yml`: dev content deployment
|
||||
- `.github/workflows/deploy-staging.yml`: staging content deployment
|
||||
- `.github/workflows/deploy-tf-poc.yml`: isolated POC content deployment
|
||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
||||
Environments → New environment → `staging`). Add protection rules as
|
||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
||||
the environment does not exist, GitHub creates it unprotected on first use.
|
||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
||||
OIDC provider and bootstrap already exist in this account):
|
||||
|
||||
All are `workflow_dispatch` only. Staging and tf-poc retain their GitHub
|
||||
environment protection and exact environment-scoped OIDC trust. Each dev and
|
||||
staging distribution ID is pinned in its workflow. The tf-poc environment
|
||||
must define its generated `CLOUDFRONT_DISTRIBUTION_ID` as a protected variable.
|
||||
Each workflow pins its environment's Swagger URL for API CORS/preflight checks.
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy shoc-frontend-staging \
|
||||
-c envName=staging \
|
||||
-c deployBranch=staging \
|
||||
-c githubEnvironment=staging \
|
||||
-c domainNames=staging.seahaven.com \
|
||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
||||
-c hostedZoneName=staging.seahaven.com
|
||||
```
|
||||
|
||||
Infrastructure creation, parent-zone delegation, Terraform imports, and
|
||||
ownership detachment remain separate administrator actions. None of these
|
||||
workflows performs them.
|
||||
The `DeployRoleArn` output must match the ARN pinned in
|
||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
||||
|
||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
||||
allow the `https://staging.seahaven.com` origin.
|
||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
||||
`deploy-staging.yml` deploys.
|
||||
|
||||
### Adding prod later
|
||||
|
||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
||||
for prod.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
Never run it against dev during or after the Terraform adoption: the
|
||||
adoption-mode stack retains the transferred resources, and after Phase 2
|
||||
Terraform owns them.
|
||||
- **CI and staging CD both fire on push to `staging`** in parallel; the
|
||||
staging CD workflow runs `npm run verify` itself before deploying. Dev has
|
||||
no push-triggered deploy during the adoption.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
|
|||
|
|
@ -1,102 +1,58 @@
|
|||
#!/usr/bin/env node
|
||||
import { App, Stack, Tags } from "aws-cdk-lib";
|
||||
import { App, Tags } from "aws-cdk-lib";
|
||||
import { FrontendStack } from "../lib/frontend-stack";
|
||||
import { TfPocCertificateStack, TfPocZoneStack } from "../lib/tf-poc-shared-stack";
|
||||
|
||||
const app = new App();
|
||||
const tfPoc = String(app.node.tryGetContext("tfPoc") ?? "false").toLowerCase() === "true";
|
||||
|
||||
if (tfPoc) {
|
||||
const pocEnv = { account: "396287094661", region: "us-east-1" };
|
||||
const tfPocPhase = String(app.node.tryGetContext("tfPocPhase") ?? "").toLowerCase();
|
||||
if (!["zone", "environment"].includes(tfPocPhase)) {
|
||||
throw new Error("tfPocPhase must be set explicitly to 'zone' or 'environment'.");
|
||||
}
|
||||
const createEnvironment = tfPocPhase === "environment";
|
||||
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||
env: pocEnv,
|
||||
terminationProtection: true,
|
||||
});
|
||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
||||
// branch-ref trust.
|
||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
||||
|
||||
const stacks: Stack[] = [zoneStack];
|
||||
if (createEnvironment) {
|
||||
const certificateStack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
|
||||
env: pocEnv,
|
||||
terminationProtection: true,
|
||||
hostedZone: zoneStack.hostedZone,
|
||||
});
|
||||
const environmentStack = new FrontendStack(app, "shoc-frontend-tf-poc", {
|
||||
envName: "tf-poc",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "tf-poc",
|
||||
githubEnvironment: "tf-poc",
|
||||
terminationProtection: true,
|
||||
domainNames: [zoneStack.hostedZoneName],
|
||||
certificateArn: certificateStack.certificateArn,
|
||||
hostedZoneId: zoneStack.hostedZoneId,
|
||||
hostedZoneName: zoneStack.hostedZoneName,
|
||||
retainForTerraformAdoption: true,
|
||||
env: pocEnv,
|
||||
});
|
||||
certificateStack.addDependency(zoneStack);
|
||||
environmentStack.addDependency(certificateStack);
|
||||
stacks.push(certificateStack, environmentStack);
|
||||
}
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
|
||||
for (const stack of stacks) {
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", "tf-poc");
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
}
|
||||
} else {
|
||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo =
|
||||
app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
||||
// branch-ref trust.
|
||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
|
||||
// ManageSiteInfrastructure parameter and Retain policies on the transferred
|
||||
// resources. Off by default so ordinary synthesis is unchanged.
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
|
||||
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() ===
|
||||
"true";
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
}
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@
|
|||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/core:defaultCrossStackReferences": "strong",
|
||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
|
||||
|
||||
"//": "Dev synthesis defaults for account 396287094661. Infrastructure deployment is administrator-run.",
|
||||
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
|
||||
"domainNames": "dev.seahaven.com",
|
||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import {
|
||||
Aspects,
|
||||
CfnCondition,
|
||||
CfnOutput,
|
||||
CfnParameter,
|
||||
CfnResource,
|
||||
Duration,
|
||||
Fn,
|
||||
RemovalPolicy,
|
||||
Stack,
|
||||
StackProps,
|
||||
|
|
@ -86,6 +89,20 @@ export class FrontendStack extends Stack {
|
|||
retainForTerraformAdoption = false,
|
||||
} = props;
|
||||
|
||||
const manageSiteInfrastructureCondition = retainForTerraformAdoption
|
||||
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
|
||||
expression: Fn.conditionEquals(
|
||||
new CfnParameter(this, "ManageSiteInfrastructure", {
|
||||
type: "String",
|
||||
allowedValues: ["true", "false"],
|
||||
description:
|
||||
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
|
||||
}).valueAsString,
|
||||
"true",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
if (hasCustomDomain && !certificateArn) {
|
||||
throw new Error(
|
||||
|
|
@ -129,10 +146,11 @@ export class FrontendStack extends Stack {
|
|||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
// Adoption mode pins the origin ID CloudFormation generated for the live
|
||||
// distribution so the retention deploy is a metadata-only update. Only
|
||||
// environments with a read-back-verified value may enter adoption mode.
|
||||
const adoptionOriginIds: Record<string, string> = {
|
||||
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
staging: "shocfrontendstagingDistributionOrigin16E4628FC",
|
||||
"tf-poc": "shoc-frontend-tf-poc-origin",
|
||||
};
|
||||
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
|
||||
if (retainForTerraformAdoption && !originId) {
|
||||
|
|
@ -287,28 +305,45 @@ export class FrontendStack extends Stack {
|
|||
});
|
||||
}
|
||||
|
||||
const gateOutput = (output: CfnOutput): CfnOutput => {
|
||||
if (manageSiteInfrastructureCondition) {
|
||||
output.condition = manageSiteInfrastructureCondition;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
});
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
});
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
});
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
});
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "Pinned GitHub OIDC content-deployment role",
|
||||
});
|
||||
gateOutput(
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "Pinned GitHub OIDC content-deployment role",
|
||||
}),
|
||||
);
|
||||
|
||||
if (retainForTerraformAdoption) {
|
||||
const originAccessControl = distribution.node
|
||||
|
|
@ -339,60 +374,92 @@ export class FrontendStack extends Stack {
|
|||
}
|
||||
|
||||
const recordName = domainNames[0];
|
||||
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||
value: `shoc-frontend-new-${envName}`,
|
||||
});
|
||||
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName });
|
||||
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||
value: bucket.bucketName,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportDistribution", {
|
||||
value: distribution.distributionId,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||
value: originAccessControl.attrId,
|
||||
});
|
||||
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||
value: originAccessControlConfig.name,
|
||||
});
|
||||
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||
value: "EMPTY_STRING",
|
||||
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||
});
|
||||
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||
value: originId!,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||
value: spaRewrite.functionName,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportAliasA", {
|
||||
value: `${hostedZoneId}_${recordName}_A`,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||
value: deployRole.roleName,
|
||||
});
|
||||
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||
});
|
||||
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||
value: rolePolicy.policyName,
|
||||
});
|
||||
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||
});
|
||||
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||
description:
|
||||
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||
});
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||
value: `shoc-frontend-new-${envName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
}),
|
||||
);
|
||||
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDistribution", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||
value: originAccessControl.attrId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||
value: originAccessControlConfig.name,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||
value: "EMPTY_STRING",
|
||||
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||
value: originId!,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||
value: spaRewrite.functionName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasA", {
|
||||
value: `${hostedZoneId}_${recordName}_A`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||
value: deployRole.roleName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||
value: rolePolicy.policyName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||
description:
|
||||
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||
}),
|
||||
);
|
||||
|
||||
Aspects.of(this).add(new RetainForTerraformAdoption());
|
||||
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
|
||||
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
|
||||
import { IConstruct } from "constructs";
|
||||
|
||||
const TRANSFERRED_RESOURCE_TYPES = new Set([
|
||||
|
|
@ -34,8 +34,12 @@ function isTransferredResource(resource: CfnResource): boolean {
|
|||
*
|
||||
* The bucket auto-delete custom resource is intentionally retained while the
|
||||
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
|
||||
* When a management condition is supplied, those same resources share it so
|
||||
* CloudFormation can later relinquish them without deleting them.
|
||||
*/
|
||||
export class RetainForTerraformAdoption implements IAspect {
|
||||
constructor(private readonly manageCondition?: CfnCondition) {}
|
||||
|
||||
public visit(node: IConstruct): void {
|
||||
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
|
||||
return;
|
||||
|
|
@ -47,10 +51,13 @@ export class RetainForTerraformAdoption implements IAspect {
|
|||
if (node.cfnResourceType === "AWS::S3::Bucket") {
|
||||
node.addOverride("DeletionPolicy", "Retain");
|
||||
node.addOverride("UpdateReplacePolicy", "Retain");
|
||||
return;
|
||||
} else {
|
||||
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||
}
|
||||
|
||||
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||
if (this.manageCondition) {
|
||||
node.cfnOptions.condition = this.manageCondition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
import { CfnOutput, Fn, Stack, StackProps } from "aws-cdk-lib";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
const TF_POC_DOMAIN = "frontend-tf-poc.seahaven.com";
|
||||
|
||||
export interface TfPocCertificateStackProps extends StackProps {
|
||||
readonly hostedZone: route53.IHostedZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary, isolated DNS zone for the production-shaped Terraform POC.
|
||||
* Parent-zone delegation is deliberately excluded from this stack.
|
||||
*/
|
||||
export class TfPocZoneStack extends Stack {
|
||||
public readonly hostedZone: route53.IHostedZone;
|
||||
public readonly hostedZoneId: string;
|
||||
public readonly hostedZoneName = TF_POC_DOMAIN;
|
||||
|
||||
public constructor(scope: Construct, id: string, props: StackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
this.hostedZone = new route53.PublicHostedZone(this, "HostedZone", {
|
||||
zoneName: TF_POC_DOMAIN,
|
||||
comment: "Temporary isolated hosted zone for frontend Terraform adoption rehearsal",
|
||||
});
|
||||
this.hostedZoneId = this.hostedZone.hostedZoneId;
|
||||
|
||||
const nameServers = this.hostedZone.hostedZoneNameServers;
|
||||
if (!nameServers) {
|
||||
throw new Error("Public hosted zone must expose delegation name servers.");
|
||||
}
|
||||
|
||||
new CfnOutput(this, "HostedZoneId", {
|
||||
value: this.hostedZone.hostedZoneId,
|
||||
description: "Terraform aws_route53_zone import ID",
|
||||
});
|
||||
new CfnOutput(this, "HostedZoneName", { value: TF_POC_DOMAIN });
|
||||
new CfnOutput(this, "DelegationNameServers", {
|
||||
value: Fn.join(",", nameServers),
|
||||
description:
|
||||
"Evidence only. Add these NS values to the seahaven.com parent zone in a separately approved change.",
|
||||
});
|
||||
new CfnOutput(this, "DelegationRecordName", {
|
||||
value: TF_POC_DOMAIN,
|
||||
});
|
||||
new CfnOutput(this, "DelegationRequiredAction", {
|
||||
value:
|
||||
"SEPARATE APPROVAL REQUIRED: create an NS record for frontend-tf-poc.seahaven.com in the parent seahaven.com zone",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate is isolated so re-running the zone phase cannot remove it.
|
||||
*/
|
||||
export class TfPocCertificateStack extends Stack {
|
||||
public readonly certificateArn: string;
|
||||
|
||||
public constructor(scope: Construct, id: string, props: TfPocCertificateStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const certificate = new acm.Certificate(this, "Certificate", {
|
||||
domainName: TF_POC_DOMAIN,
|
||||
validation: acm.CertificateValidation.fromDns(props.hostedZone),
|
||||
});
|
||||
this.certificateArn = certificate.certificateArn;
|
||||
|
||||
new CfnOutput(this, "CertificateArn", {
|
||||
value: certificate.certificateArn,
|
||||
description: "Inventory-only certificate ARN for the frontend tf-poc root",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,7 @@
|
|||
"build": "tsc",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"synth": "cdk synth",
|
||||
"synth:tf-poc-zone": "cdk synth -c tfPoc=true -c tfPocPhase=zone",
|
||||
"synth:tf-poc-environment": "cdk synth -c tfPoc=true -c tfPocPhase=environment",
|
||||
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
|
||||
"diff": "cdk diff",
|
||||
"deploy": "cdk deploy"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,29 +1,41 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import { test } from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { App } = require("aws-cdk-lib");
|
||||
const { Template } = require("aws-cdk-lib/assertions");
|
||||
const { FrontendStack } = require("../lib/frontend-stack.js");
|
||||
const { TfPocCertificateStack, TfPocZoneStack } = require("../lib/tf-poc-shared-stack.js");
|
||||
|
||||
const account = "396287094661";
|
||||
const region = "us-east-1";
|
||||
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
|
||||
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
|
||||
const CONDITION = "ManageSiteInfrastructureCondition";
|
||||
|
||||
function frontendTemplate(retainForTerraformAdoption) {
|
||||
const RETAINED_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
"Custom::S3AutoDeleteObjects",
|
||||
]);
|
||||
|
||||
function devTemplate(retainForTerraformAdoption, overrides = {}) {
|
||||
const app = new App();
|
||||
const stack = new FrontendStack(app, "shoc-frontend-tf-poc", {
|
||||
envName: "tf-poc",
|
||||
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
||||
envName: "dev",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "tf-poc",
|
||||
githubEnvironment: "tf-poc",
|
||||
domainNames: ["frontend-tf-poc.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`,
|
||||
hostedZoneId: "ZTESTPOC",
|
||||
hostedZoneName: "frontend-tf-poc.seahaven.com",
|
||||
deployBranch: "dev",
|
||||
domainNames: ["dev.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
|
||||
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||
hostedZoneName: "dev.seahaven.com",
|
||||
retainForTerraformAdoption,
|
||||
env: { account, region },
|
||||
...overrides,
|
||||
});
|
||||
return Template.fromStack(stack).toJSON();
|
||||
}
|
||||
|
|
@ -32,10 +44,35 @@ function entriesByType(template, type) {
|
|||
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
||||
}
|
||||
|
||||
test("tf-poc has fixed production-shaped resources and adoption metadata", () => {
|
||||
const template = frontendTemplate(true);
|
||||
function isTransferred(logicalId, resource) {
|
||||
const isDeployRoleResource =
|
||||
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
|
||||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
|
||||
}
|
||||
|
||||
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
|
||||
const template = devTemplate(true);
|
||||
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
||||
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
||||
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
|
||||
isTransferred(id, resource),
|
||||
);
|
||||
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
|
||||
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
|
||||
// addresses) plus the retained custom resource.
|
||||
assert.equal(transferred.length, 10);
|
||||
});
|
||||
|
||||
test("adoption mode preserves the live dev identifiers", () => {
|
||||
const template = devTemplate(true);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-tf-poc");
|
||||
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
|
||||
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
||||
assert.ok(
|
||||
bucket.Properties.Tags.some(
|
||||
|
|
@ -43,30 +80,43 @@ test("tf-poc has fixed production-shaped resources and adoption metadata", () =>
|
|||
),
|
||||
);
|
||||
|
||||
const [deployRole] = entriesByType(template, "AWS::IAM::Role").filter(
|
||||
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc",
|
||||
);
|
||||
assert.ok(deployRole);
|
||||
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
|
||||
assert.equal(
|
||||
deployRole[1].Properties.PermissionsBoundary,
|
||||
`arn:aws:iam::${account}:policy/shoc-frontend-new-tf-poc-deploy-boundary`,
|
||||
);
|
||||
assert.ok(
|
||||
deployRole[1].Properties.Tags.some(
|
||||
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-tf-poc",
|
||||
),
|
||||
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
|
||||
DEV_ORIGIN_ID,
|
||||
);
|
||||
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||
assert.equal(
|
||||
entriesByType(template, "AWS::CloudFront::Distribution")[0][1].Properties.DistributionConfig
|
||||
.Origins[0].Id,
|
||||
"shoc-frontend-tf-poc-origin",
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
||||
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
||||
assert.equal(
|
||||
deployRole.Properties.PermissionsBoundary,
|
||||
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
|
||||
);
|
||||
assert.ok(
|
||||
deployRole.Properties.Tags.some(
|
||||
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
|
||||
),
|
||||
);
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(condition.StringLike, undefined);
|
||||
|
||||
// Legacy inline policy stays byte-compatible with the live document.
|
||||
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
|
||||
id.startsWith("GithubDeployRole"),
|
||||
);
|
||||
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
|
||||
assert.deepEqual(sids, [
|
||||
"AssumeCdkBootstrapRoles",
|
||||
"DescribeStack",
|
||||
undefined,
|
||||
"InvalidateDistribution",
|
||||
]);
|
||||
|
||||
for (const output of [
|
||||
"TerraformWorkspaceTag",
|
||||
|
|
@ -89,27 +139,17 @@ test("tf-poc has fixed production-shaped resources and adoption metadata", () =>
|
|||
]) {
|
||||
assert.ok(template.Outputs[output], `missing output ${output}`);
|
||||
}
|
||||
assert.equal(
|
||||
template.Outputs.TerraformImportAliasA.Value,
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
|
||||
});
|
||||
|
||||
test("adoption mode retains exactly the transferred resources", () => {
|
||||
const template = frontendTemplate(true);
|
||||
const retainedTypes = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
"Custom::S3AutoDeleteObjects",
|
||||
]);
|
||||
|
||||
const template = devTemplate(true);
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
const isDeployRoleResource =
|
||||
(resource.Type === "AWS::IAM::Role" &&
|
||||
resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc") ||
|
||||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||
const shouldRetain = retainedTypes.has(resource.Type) || isDeployRoleResource;
|
||||
if (shouldRetain) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
} else {
|
||||
|
|
@ -117,88 +157,69 @@ test("adoption mode retains exactly the transferred resources", () => {
|
|||
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
}
|
||||
}
|
||||
|
||||
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||
assert.equal(customResource.DeletionPolicy, "Retain");
|
||||
// The auto-delete provider Lambda, role, and log group stay unretained.
|
||||
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
||||
for (const [, resource] of entriesByType(template, type)) {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain");
|
||||
}
|
||||
}
|
||||
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
|
||||
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
|
||||
);
|
||||
assert.equal(providerRoles.length, 1);
|
||||
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
|
||||
});
|
||||
|
||||
test("normal mode preserves destructive cleanup and has no adoption boundary or tag", () => {
|
||||
const template = frontendTemplate(false);
|
||||
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
|
||||
const template = devTemplate(true);
|
||||
const parameter = template.Parameters.ManageSiteInfrastructure;
|
||||
assert.ok(parameter);
|
||||
assert.equal(parameter.Type, "String");
|
||||
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
|
||||
assert.equal(parameter.Default, undefined);
|
||||
assert.ok(template.Conditions[CONDITION]);
|
||||
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.Condition, CONDITION, logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.Condition, CONDITION, logicalId);
|
||||
}
|
||||
}
|
||||
for (const [outputName, output] of Object.entries(template.Outputs)) {
|
||||
assert.equal(output.Condition, CONDITION, outputName);
|
||||
}
|
||||
});
|
||||
|
||||
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
|
||||
const template = devTemplate(false);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.DeletionPolicy, "Delete");
|
||||
assert.equal(bucket.UpdateReplacePolicy, "Delete");
|
||||
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||
assert.notEqual(customResource.DeletionPolicy, "Retain");
|
||||
|
||||
const deployRole = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc",
|
||||
)[1];
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
||||
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
||||
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||
});
|
||||
|
||||
test("dev adoption prerequisite preserves origin ID and narrows exact trust", () => {
|
||||
const app = new App();
|
||||
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
||||
envName: "dev",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "dev",
|
||||
domainNames: ["dev.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`,
|
||||
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||
hostedZoneName: "dev.seahaven.com",
|
||||
retainForTerraformAdoption: true,
|
||||
env: { account, region },
|
||||
});
|
||||
const template = Template.fromStack(stack).toJSON();
|
||||
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||
assert.equal(
|
||||
distribution.Properties.DistributionConfig.Origins[0].Id,
|
||||
"shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
);
|
||||
|
||||
const deployRole = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-dev",
|
||||
)[1];
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
||||
condition.StringLike["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(condition.StringLike, undefined);
|
||||
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
|
||||
assert.equal(template.Conditions?.[CONDITION], undefined);
|
||||
for (const resource of Object.values(template.Resources)) {
|
||||
assert.equal(resource.Condition, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("zone stack never owns a certificate or parent delegation", () => {
|
||||
const app = new App();
|
||||
const stack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||
env: { account, region },
|
||||
});
|
||||
const template = Template.fromStack(stack).toJSON();
|
||||
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 0);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 0);
|
||||
assert.ok(template.Outputs.DelegationNameServers);
|
||||
assert.equal(template.Outputs.CertificateArn, undefined);
|
||||
assert.match(template.Outputs.DelegationRequiredAction.Value, /SEPARATE APPROVAL REQUIRED/);
|
||||
});
|
||||
|
||||
test("environment phase creates its certificate in a separate stack", () => {
|
||||
const app = new App();
|
||||
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
|
||||
env: { account, region },
|
||||
});
|
||||
const stack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
|
||||
env: { account, region },
|
||||
hostedZone: zoneStack.hostedZone,
|
||||
});
|
||||
const template = Template.fromStack(stack).toJSON();
|
||||
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 0);
|
||||
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 1);
|
||||
assert.ok(template.Outputs.CertificateArn);
|
||||
test("adoption mode refuses an environment without a verified origin ID", () => {
|
||||
assert.throws(
|
||||
() => devTemplate(true, { envName: "staging" }),
|
||||
/No verified Terraform adoption origin ID exists for staging/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
308
package-lock.json
generated
308
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
@ -1974,6 +1976,295 @@
|
|||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz",
|
||||
"integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/feedback": "10.73.0",
|
||||
"@sentry/replay": "10.73.0",
|
||||
"@sentry/replay-canvas": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz",
|
||||
"integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz",
|
||||
"integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"progress": "^2.0.3",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"undici": "^6.22.0",
|
||||
"which": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-cli": "bin/sentry-cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "3.7.0",
|
||||
"@sentry/cli-linux-arm": "3.7.0",
|
||||
"@sentry/cli-linux-arm64": "3.7.0",
|
||||
"@sentry/cli-linux-i686": "3.7.0",
|
||||
"@sentry/cli-linux-x64": "3.7.0",
|
||||
"@sentry/cli-win32-arm64": "3.7.0",
|
||||
"@sentry/cli-win32-i686": "3.7.0",
|
||||
"@sentry/cli-win32-x64": "3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz",
|
||||
"integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==",
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz",
|
||||
"integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz",
|
||||
"integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz",
|
||||
"integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz",
|
||||
"integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz",
|
||||
"integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz",
|
||||
"integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/replay": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@simple-libs/child-process-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
|
||||
|
|
@ -5736,6 +6027,16 @@
|
|||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5753,6 +6054,13 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:deploy-web": "node scripts/deploy-web.test.mjs",
|
||||
"test:terraform-import-plan": "python scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:tf-poc-zone && npm --prefix infra/cdk run synth:tf-poc-environment",
|
||||
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -83,6 +84,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
|
|||
120
scripts/check-terraform-isolation.mjs
Normal file
120
scripts/check-terraform-isolation.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// 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.
|
||||
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/");
|
||||
}
|
||||
|
||||
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;
|
||||
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(isTerraformPath);
|
||||
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)));
|
||||
}
|
||||
115
scripts/check-terraform-isolation.test.mjs
Normal file
115
scripts/check-terraform-isolation.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
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",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, CDK, 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",
|
||||
"infra/cdk/lib/frontend-stack.ts",
|
||||
"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 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("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
|
@ -1,438 +1,79 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
#
|
||||
# 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
|
||||
|
||||
required_vars=(
|
||||
SITE_BUCKET
|
||||
EXPECTED_SITE_BUCKET
|
||||
CLOUDFRONT_DISTRIBUTION_ID
|
||||
SITE_URL
|
||||
EXPECTED_API_URL
|
||||
)
|
||||
for name in "${required_vars[@]}"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "::error::${name} must be set explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}"
|
||||
EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}"
|
||||
FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}"
|
||||
API_SMOKE_URL="${API_SMOKE_URL:-}"
|
||||
API_CORS_ORIGIN="${API_CORS_ORIGIN:-}"
|
||||
|
||||
if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then
|
||||
echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then
|
||||
echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then
|
||||
echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then
|
||||
echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then
|
||||
echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"
|
||||
API_CORS_ORIGIN="${API_CORS_ORIGIN%/}"
|
||||
work_dir="$(mktemp -d)"
|
||||
published_index_version=""
|
||||
prior_index_version=""
|
||||
deployment_verified="false"
|
||||
|
||||
invalidate_and_wait() {
|
||||
local invalidation_id
|
||||
invalidation_id="$(
|
||||
aws cloudfront create-invalidation \
|
||||
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||
--paths "/*" \
|
||||
--query "Invalidation.Id" \
|
||||
--output text
|
||||
)"
|
||||
test -n "${invalidation_id}"
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||
--id "${invalidation_id}"
|
||||
}
|
||||
|
||||
rollback_index() {
|
||||
[[ -n "${published_index_version}" ]] || return 0
|
||||
echo "::warning::Verification failed. Restoring the prior index version." >&2
|
||||
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
|
||||
aws s3api copy-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key index.html \
|
||||
--copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html" \
|
||||
--metadata-directive REPLACE >/dev/null
|
||||
else
|
||||
aws s3api delete-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key index.html \
|
||||
--version-id "${published_index_version}" >/dev/null
|
||||
fi
|
||||
invalidate_and_wait || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then
|
||||
rollback_index
|
||||
fi
|
||||
rm -rf "${work_dir}"
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
versioning_status="$(
|
||||
aws s3api get-bucket-versioning \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--query Status \
|
||||
--output text
|
||||
)"
|
||||
if [[ "${versioning_status}" != "Enabled" ]]; then
|
||||
echo "::error::The target bucket must have versioning enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! prior_index_version="$(
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key index.html \
|
||||
--query VersionId \
|
||||
--output text 2>"${work_dir}/prior-index.error"
|
||||
)"; then
|
||||
if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then
|
||||
prior_index_version=""
|
||||
else
|
||||
cat "${work_dir}/prior-index.error" >&2
|
||||
echo "::error::Could not inspect the current index version." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
: >"${work_dir}/prior-asset-versions.tsv"
|
||||
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
|
||||
prior_manifest_key="$(
|
||||
aws s3api list-objects-v2 \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--prefix ".deploy/releases/" \
|
||||
--query "reverse(sort_by(Contents,&LastModified))[0].Key" \
|
||||
--output text
|
||||
)"
|
||||
if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then
|
||||
aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \
|
||||
"${work_dir}/prior-manifest.json" --quiet
|
||||
node - "${work_dir}/prior-manifest.json" \
|
||||
>"${work_dir}/prior-asset-versions.tsv" <<'NODE'
|
||||
const manifest = require(process.argv[2]);
|
||||
for (const asset of manifest.assets ?? []) {
|
||||
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||
console.log(`${asset.key}\t${asset.versionId}`);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
else
|
||||
aws s3api list-objects-v2 \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--query "Contents[].Key" \
|
||||
--output text | tr "\t" "\n" \
|
||||
| awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \
|
||||
| sort -u >"${work_dir}/prior-asset-keys.txt"
|
||||
while IFS= read -r prior_asset_key; do
|
||||
[[ -n "${prior_asset_key}" ]] || continue
|
||||
prior_asset_version="$(
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${prior_asset_key}" \
|
||||
--query VersionId \
|
||||
--output text
|
||||
)"
|
||||
if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then
|
||||
echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \
|
||||
>>"${work_dir}/prior-asset-versions.tsv"
|
||||
done <"${work_dir}/prior-asset-keys.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Building SPA for ${EXPECTED_API_URL}..."
|
||||
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
|
||||
test -s dist/index.html
|
||||
if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then
|
||||
echo "::error::Built output does not contain EXPECTED_API_URL." >&2
|
||||
|
||||
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
|
||||
fi
|
||||
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
|
||||
if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then
|
||||
echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2
|
||||
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
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Publishing immutable release assets..."
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
|
||||
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"
|
||||
|
||||
published_index_version="$(
|
||||
aws s3api put-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key index.html \
|
||||
--body dist/index.html \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html" \
|
||||
--query VersionId \
|
||||
--output text
|
||||
)"
|
||||
if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then
|
||||
echo "::error::Index upload did not return a version ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node - >"${work_dir}/asset-keys.txt" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
function files(directory, prefix = "") {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const relative = path.posix.join(prefix, entry.name);
|
||||
return entry.isDirectory()
|
||||
? files(path.join(directory, entry.name), relative)
|
||||
: [relative];
|
||||
});
|
||||
}
|
||||
for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) {
|
||||
console.log(file);
|
||||
}
|
||||
NODE
|
||||
: >"${work_dir}/asset-versions.tsv"
|
||||
while IFS= read -r asset_key; do
|
||||
asset_version="$(
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${asset_key}" \
|
||||
--query VersionId \
|
||||
--output text
|
||||
)"
|
||||
if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then
|
||||
echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv"
|
||||
done <"${work_dir}/asset-keys.txt"
|
||||
|
||||
node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \
|
||||
"${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \
|
||||
>"${work_dir}/manifest.json" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] =
|
||||
process.argv.slice(2);
|
||||
function readVersions(path) {
|
||||
return fs
|
||||
.readFileSync(path, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [key, versionId] = line.split("\t");
|
||||
return { key, versionId };
|
||||
});
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
release,
|
||||
indexVersion,
|
||||
priorIndexVersion,
|
||||
assets: readVersions(versionsPath),
|
||||
priorAssets: readVersions(priorVersionsPath),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
NODE
|
||||
manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json"
|
||||
|
||||
echo "Invalidating CloudFront and waiting for propagation..."
|
||||
invalidate_and_wait
|
||||
|
||||
fetch_route() {
|
||||
local route="$1"
|
||||
local slug="$2"
|
||||
curl -fsS --max-time 30 \
|
||||
-D "${work_dir}/${slug}.headers" \
|
||||
-o "${work_dir}/${slug}.body" \
|
||||
"${SITE_URL}${route}"
|
||||
grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers"
|
||||
grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers"
|
||||
grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers"
|
||||
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers"
|
||||
cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body"
|
||||
}
|
||||
|
||||
curl -fsS --max-time 30 \
|
||||
-D "${work_dir}/root.headers" \
|
||||
-o "${work_dir}/root.body" \
|
||||
"${SITE_URL}/"
|
||||
grep -qi "^content-type:.*text/html" "${work_dir}/root.headers"
|
||||
grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers"
|
||||
grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers"
|
||||
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers"
|
||||
fetch_route "/login" "login"
|
||||
fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless"
|
||||
|
||||
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \
|
||||
| awk -F'"' '{ print $2 }' \
|
||||
| sort -u >"${work_dir}/asset-paths.txt"
|
||||
test -s "${work_dir}/asset-paths.txt"
|
||||
: >"${work_dir}/asset-content.txt"
|
||||
while IFS= read -r asset_path; do
|
||||
asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")"
|
||||
curl -fsS --max-time 30 \
|
||||
-D "${work_dir}/${asset_slug}.headers" \
|
||||
-o "${work_dir}/${asset_slug}.body" \
|
||||
"${SITE_URL}${asset_path}"
|
||||
grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers"
|
||||
grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers"
|
||||
cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt"
|
||||
done <"${work_dir}/asset-paths.txt"
|
||||
|
||||
grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt"
|
||||
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
|
||||
if [[ -n "${forbidden_url}" ]] &&
|
||||
grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then
|
||||
echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${API_SMOKE_URL}" ]]; then
|
||||
api_status="$(
|
||||
curl -sS --max-time 30 \
|
||||
-H "Origin: ${API_CORS_ORIGIN}" \
|
||||
-D "${work_dir}/api.headers" \
|
||||
-o "${work_dir}/api.body" \
|
||||
-w "%{http_code}" \
|
||||
"${API_SMOKE_URL}"
|
||||
)"
|
||||
if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then
|
||||
echo "::error::API smoke request failed with HTTP ${api_status}." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers"
|
||||
|
||||
curl -fsS --max-time 30 \
|
||||
-X OPTIONS \
|
||||
-H "Origin: ${API_CORS_ORIGIN}" \
|
||||
-H "Access-Control-Request-Method: GET" \
|
||||
-D "${work_dir}/cors.headers" \
|
||||
-o /dev/null \
|
||||
"${API_SMOKE_URL}"
|
||||
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers"
|
||||
grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers"
|
||||
fi
|
||||
|
||||
aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \
|
||||
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 "application/json"
|
||||
--content-type "text/html"
|
||||
|
||||
# Once the manifest is durable, this release and its rollback target are both
|
||||
# protected from pruning. A later cleanup failure must not roll back a release
|
||||
# whose prior assets may already have been pruned.
|
||||
deployment_verified="true"
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
# Keep exactly the current and immediately prior release manifests and every
|
||||
# object version they reference. Prune only unreferenced versions, after all
|
||||
# remote checks pass.
|
||||
aws s3api list-objects-v2 \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--prefix ".deploy/releases/" \
|
||||
--query "reverse(sort_by(Contents,&LastModified))[].Key" \
|
||||
--output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt"
|
||||
printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt"
|
||||
awk -v current="${manifest_key}" '$0 != current { print; exit }' \
|
||||
"${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt"
|
||||
: >"${work_dir}/retained-versions.tsv"
|
||||
while IFS= read -r kept_manifest; do
|
||||
[[ -n "${kept_manifest}" ]] || continue
|
||||
aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet
|
||||
kept_manifest_version="$(
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${kept_manifest}" \
|
||||
--query VersionId \
|
||||
--output text
|
||||
)"
|
||||
printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \
|
||||
>>"${work_dir}/retained-versions.tsv"
|
||||
is_current_manifest="false"
|
||||
if [[ "${kept_manifest}" == "${manifest_key}" ]]; then
|
||||
is_current_manifest="true"
|
||||
fi
|
||||
node - "${work_dir}/kept.json" "${is_current_manifest}" \
|
||||
>>"${work_dir}/retained-versions.tsv" <<'NODE'
|
||||
const manifest = require(process.argv[2]);
|
||||
const isCurrentManifest = process.argv[3] === "true";
|
||||
if (manifest.indexVersion && manifest.indexVersion !== "None") {
|
||||
console.log(`index.html\t${manifest.indexVersion}`);
|
||||
}
|
||||
if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") {
|
||||
console.log(`index.html\t${manifest.priorIndexVersion}`);
|
||||
}
|
||||
for (const asset of manifest.assets) {
|
||||
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||
console.log(`${asset.key}\t${asset.versionId}`);
|
||||
}
|
||||
}
|
||||
if (isCurrentManifest) {
|
||||
for (const asset of manifest.priorAssets ?? []) {
|
||||
if (typeof asset === "object" && asset.key && asset.versionId) {
|
||||
console.log(`${asset.key}\t${asset.versionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
NODE
|
||||
done <"${work_dir}/kept-manifests.txt"
|
||||
sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv"
|
||||
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
|
||||
|
||||
aws s3api list-object-versions \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--output json >"${work_dir}/object-versions.json"
|
||||
node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE'
|
||||
const listing = require(process.argv[2]);
|
||||
for (const version of listing.Versions ?? []) {
|
||||
console.log(`version\t${version.Key}\t${version.VersionId}`);
|
||||
}
|
||||
for (const marker of listing.DeleteMarkers ?? []) {
|
||||
console.log(`marker\t${marker.Key}\t${marker.VersionId}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
while IFS=$'\t' read -r kind object_key version_id; do
|
||||
[[ -n "${object_key}" && -n "${version_id}" ]] || continue
|
||||
if [[ "${kind}" == "version" ]] &&
|
||||
grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then
|
||||
continue
|
||||
fi
|
||||
aws s3api delete-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${object_key}" \
|
||||
--version-id "${version_id}" >/dev/null
|
||||
done <"${work_dir}/prune-candidates.tsv"
|
||||
|
||||
echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified."
|
||||
echo "Web deploy complete."
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptPath = new URL("./deploy-web.sh", import.meta.url);
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
const windowsGitBash = `${process.env.ProgramFiles ?? "C:\\Program Files"}\\Git\\bin\\bash.exe`;
|
||||
const bash = process.platform === "win32" ? windowsGitBash : "bash";
|
||||
const hasBash = process.platform !== "win32" || existsSync(windowsGitBash);
|
||||
const nativeScriptPath = fileURLToPath(scriptPath);
|
||||
const bashScriptPath =
|
||||
process.platform === "win32"
|
||||
? nativeScriptPath
|
||||
.replace(/^([A-Za-z]):\\/, (_, drive) => `/${drive.toLowerCase()}/`)
|
||||
.replaceAll("\\", "/")
|
||||
: nativeScriptPath;
|
||||
|
||||
test("deploy script has valid bash syntax", { skip: !hasBash }, () => {
|
||||
const result = spawnSync(bash, ["-n", bashScriptPath], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test("deploy script fails closed before running tools", { skip: !hasBash }, () => {
|
||||
const result = spawnSync(bash, [bashScriptPath], {
|
||||
encoding: "utf8",
|
||||
env: { PATH: process.env.PATH },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /SITE_BUCKET must be set explicitly/);
|
||||
});
|
||||
|
||||
test("target bucket mismatch fails before publishing", { skip: !hasBash }, () => {
|
||||
const result = spawnSync(bash, [bashScriptPath], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SITE_BUCKET: "wrong-bucket",
|
||||
EXPECTED_SITE_BUCKET: "expected-bucket",
|
||||
CLOUDFRONT_DISTRIBUTION_ID: "DIST123",
|
||||
SITE_URL: "https://example.test",
|
||||
EXPECTED_API_URL: "https://api.example.test/api",
|
||||
VITE_API_URL: "https://api.example.test/api",
|
||||
DEPLOY_RELEASE_ID: "1234567",
|
||||
},
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /SITE_BUCKET does not match EXPECTED_SITE_BUCKET/);
|
||||
});
|
||||
|
||||
test("content safety contract is present and ordered", () => {
|
||||
for (const required of [
|
||||
"get-bucket-versioning",
|
||||
"head-object",
|
||||
"list-object-versions",
|
||||
"asset-versions.tsv",
|
||||
"prior-asset-versions.tsv",
|
||||
"prior-manifest.json",
|
||||
"priorAssets",
|
||||
"isCurrentManifest",
|
||||
"--version-id",
|
||||
"public,max-age=31536000,immutable",
|
||||
"no-cache,no-store,must-revalidate",
|
||||
"cloudfront wait invalidation-completed",
|
||||
'fetch_route "/login"',
|
||||
'fetch_route "${EXTENSIONLESS_SMOKE_PATH}"',
|
||||
"Access-Control-Request-Method: GET",
|
||||
".deploy/releases/${DEPLOY_RELEASE_ID}.json",
|
||||
]) {
|
||||
assert.ok(script.includes(required), `missing contract: ${required}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
script.indexOf('deployment_verified="true"') < script.indexOf("aws s3api list-object-versions"),
|
||||
"pruning must occur only after remote verification",
|
||||
);
|
||||
assert.ok(
|
||||
script.indexOf('grep -qi "^access-control-allow-methods:.*GET"') <
|
||||
script.indexOf('aws s3 cp "${work_dir}/manifest.json"'),
|
||||
"failed remote verification must not publish a retention manifest",
|
||||
);
|
||||
assert.ok(
|
||||
script.indexOf('aws s3 cp "${work_dir}/manifest.json"') <
|
||||
script.indexOf('deployment_verified="true"'),
|
||||
"manifest publication failures must roll back the new index",
|
||||
);
|
||||
assert.ok(
|
||||
script.indexOf('>"${work_dir}/prior-asset-keys.txt"') <
|
||||
script.indexOf('aws s3 sync dist/ "s3://${SITE_BUCKET}/"'),
|
||||
"the pre-manifest release must be inventoried before new assets publish",
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/if \(isCurrentManifest\) \{[\s\S]*manifest\.priorAssets/,
|
||||
"only the current manifest may retain its pre-script rollback assets",
|
||||
);
|
||||
assert.ok(
|
||||
script.indexOf('SITE_URL="${SITE_URL%/}"') <
|
||||
script.indexOf('API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"'),
|
||||
"the default CORS origin must use the normalized site URL",
|
||||
);
|
||||
assert.match(script, /Could not inspect the current index version/);
|
||||
assert.doesNotMatch(script, /s3 sync[\s\S]{0,250}--delete/);
|
||||
});
|
||||
|
|
@ -17,11 +17,13 @@ const MAINTAINABILITY_RULES = [
|
|||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
// Repository-level gates that run after the source gates. Each is an npm
|
||||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["Web deployment contract", "test:deploy-web"],
|
||||
["CDK build and synth", "test:infra"],
|
||||
["CDK build, tests, and synth", "test:infra"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
|
|
@ -201,6 +203,8 @@ function plural(count, word) {
|
|||
}
|
||||
|
||||
function runRepositoryGate(label, script) {
|
||||
// Reuse the npm that launched us when available (matches its version and
|
||||
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
|
||||
const npmCli = process.env.npm_execpath;
|
||||
const executable = npmCli ? process.execPath : "npm";
|
||||
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||
|
|
@ -209,11 +213,7 @@ function runRepositoryGate(label, script) {
|
|||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
return {
|
||||
label,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
};
|
||||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { fileURLToPath } from "node:url";
|
|||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||
const ROOTS = ["tf-poc", "dev", "staging"].map((environment) =>
|
||||
path.join(ROOT, "terraform", "live", environment),
|
||||
);
|
||||
// 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));
|
||||
|
||||
function run(args, cwd = ROOT) {
|
||||
const result = spawnSync(TERRAFORM, args, {
|
||||
|
|
@ -26,8 +26,10 @@ function run(args, cwd = ROOT) {
|
|||
|
||||
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||
for (const root of ROOTS) {
|
||||
run(["init", "-backend=false", "-input=false", "-no-color"], root);
|
||||
// -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);
|
||||
}
|
||||
|
||||
console.log("Terraform formatting and validation passed for tf-poc, dev, and staging.");
|
||||
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||
|
|
@ -30,7 +35,7 @@ COMMON_RESOURCES = {
|
|||
|
||||
REQUIRED_RESOURCES = {
|
||||
environment: dict(COMMON_RESOURCES)
|
||||
for environment in ("dev", "staging", "tf-poc")
|
||||
for environment in ("dev", "staging")
|
||||
}
|
||||
|
||||
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||
|
|
@ -65,16 +70,6 @@ ENVIRONMENT_CONFIG = {
|
|||
"distribution_id": "E2JDVEZ6EGD49J",
|
||||
"workspace_name": "shoc-frontend-new-staging",
|
||||
},
|
||||
"tf-poc": {
|
||||
"bucket_name": "seahaven-shoc-frontend-tf-poc",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-tf-poc-CustomS3AutoDeleteObjectsCusto-GBCGk2k2ZORz"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-tf-poc",
|
||||
"distribution_id": None,
|
||||
"workspace_name": "shoc-frontend-new-tf-poc",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -133,16 +128,4 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
|||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
"tf-poc": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-tf-poc"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": None,
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": None,
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": None,
|
||||
"module.environment_owned.aws_route53_record.site_a": None,
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": None,
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-tf-poc"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": None,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,15 +32,14 @@ TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
|||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if expected is not None:
|
||||
return expected
|
||||
suffix = address.rsplit(".", 1)[-1].replace("_", "-")
|
||||
return f"tf-poc-generated-{suffix}"
|
||||
assert expected is not None, f"{environment} must pin an import ID for {address}"
|
||||
return expected
|
||||
|
||||
|
||||
def distribution_id(environment: str) -> str:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
return configured if isinstance(configured, str) else "ETFPOCGENERATED123"
|
||||
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
|
||||
return configured
|
||||
|
||||
|
||||
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||
|
|
@ -388,6 +387,26 @@ 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_dev_root_pins_import_phase_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+= false\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_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
expected = {
|
||||
"dev": (
|
||||
|
|
@ -396,18 +415,6 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
|||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
"staging": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
"tf-poc": (
|
||||
"var.hosted_zone_id",
|
||||
"var.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
|
|
|
|||
24
scripts/upload-sourcemaps.sh
Executable file
24
scripts/upload-sourcemaps.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}"
|
||||
|
||||
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
|
||||
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
|
||||
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_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}"
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
--release "${RELEASE}" \
|
||||
--url-prefix "~/" \
|
||||
--validate \
|
||||
dist
|
||||
|
|
@ -37,6 +37,8 @@ export const API_PATHS = {
|
|||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
141
src/api/api.ts
141
src/api/api.ts
|
|
@ -3,18 +3,9 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function isAuthLoginRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.login);
|
||||
|
|
@ -51,35 +42,99 @@ export const api: KyInstance = ky.create({
|
|||
return response;
|
||||
}
|
||||
|
||||
clearSessionAndRedirectToLogin();
|
||||
expireSessionAndRedirectToLogin();
|
||||
return response;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
|
||||
|
||||
export async function apiRequestRaw(
|
||||
method: ApiRawMethod,
|
||||
url: string,
|
||||
options?: Options,
|
||||
entryPoint?: string,
|
||||
): Promise<Response> {
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
entryPoint: entryPoint ?? `api.${method}`,
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api[method](url, options);
|
||||
reportStatus(response.status);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.get(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "GET", url, entryPoint: "api.apiGet" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.get(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPost" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostForm" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PUT", url, entryPoint: "api.apiPut" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.put(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.patch(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.patch(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostNoContent(
|
||||
|
|
@ -87,31 +142,43 @@ export async function apiPostNoContent(
|
|||
body?: unknown,
|
||||
options?: Options,
|
||||
): Promise<void> {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||
const response = await api.delete(url, options);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { mapHttpStatusToMessage };
|
||||
|
|
|
|||
103
src/api/upload-form-with-progress.ts
Normal file
103
src/api/upload-form-with-progress.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
|
||||
export interface UploadFormResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface UploadFormOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
function parseJsonBody(text: string): unknown {
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException("The upload was aborted.", "AbortError");
|
||||
}
|
||||
|
||||
/**
|
||||
* hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept
|
||||
* streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies
|
||||
* as a network error before reaching the API. XHR reports progress on every transport.
|
||||
*/
|
||||
export function uploadFormWithProgress(
|
||||
path: string,
|
||||
body: FormData,
|
||||
options?: UploadFormOptions,
|
||||
): Promise<UploadFormResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.setRequestHeader("Authorization", token);
|
||||
}
|
||||
|
||||
const abortRequest = () => {
|
||||
request.abort();
|
||||
};
|
||||
options?.signal?.addEventListener("abort", abortRequest);
|
||||
|
||||
const detachSignal = () => {
|
||||
options?.signal?.removeEventListener("abort", abortRequest);
|
||||
};
|
||||
|
||||
const { onProgress } = options ?? {};
|
||||
if (onProgress) {
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
detachSignal();
|
||||
const { status } = request;
|
||||
|
||||
if (status === 401) {
|
||||
expireSessionAndRedirectToLogin();
|
||||
}
|
||||
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
data: parseJsonBody(request.responseText),
|
||||
});
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
detachSignal();
|
||||
reject(new Error("Upload failed. Check your connection and try again."));
|
||||
});
|
||||
|
||||
request.addEventListener("abort", () => {
|
||||
detachSignal();
|
||||
reject(createAbortError());
|
||||
});
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
|
|
@ -20,9 +21,9 @@ import {
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
|
|||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
toast.error("Unable to open this document. Please allow pop-ups and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
tab.opener = null;
|
||||
tab.location.href = objectUrl;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch {
|
||||
tab.close();
|
||||
toast.error("Unable to open this document.");
|
||||
}
|
||||
}
|
||||
|
||||
function ExtraDocRow({
|
||||
file,
|
||||
isReadOnly,
|
||||
pending,
|
||||
workOrderId,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: {
|
||||
file: MediaFile;
|
||||
isReadOnly: boolean;
|
||||
pending: boolean;
|
||||
workOrderId?: string | number;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
}) {
|
||||
|
|
@ -75,15 +98,20 @@ function ExtraDocRow({
|
|||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
disabled={workOrderId == null}
|
||||
onClick={() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
void openExtraDocContent(workOrderId, file.id);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
</button>
|
||||
{showCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
|
|
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
|
|||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
|
|||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
|
|||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
|
|||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
|
|||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
workOrderId={workOrderId}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { DocBadge } from "./slide-over-doc-badge";
|
||||
|
||||
|
|
@ -31,6 +32,75 @@ type SlideOverInfoTabEditViewProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
function SlideOverScheduleOnField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: WorkOrderTableRow;
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
}) {
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
|
||||
return (
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({
|
||||
scheduledOn,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: scheduledOn ? "" : draft.targetWeek,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(scheduledOn),
|
||||
})
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onDraftChange({
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(start),
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlideOverInfoTabEditView({
|
||||
draft,
|
||||
users,
|
||||
|
|
@ -50,6 +120,9 @@ export function SlideOverInfoTabEditView({
|
|||
rescheduleCount={draft.rescheduleCount}
|
||||
carriedOver={draft.carriedOver}
|
||||
originalDate={draft.originalDate}
|
||||
originalWeek={draft.originalWeek}
|
||||
scheduledOn={draft.scheduledOn}
|
||||
status={draft.status}
|
||||
currentId={draft.id}
|
||||
allOrders={allOrders}
|
||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||
|
|
@ -96,23 +169,7 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Schedule On">
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
isPastDue: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SlideOverScheduleOnField draft={draft} onDraftChange={onDraftChange} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Due Date">
|
||||
<DueDateCell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
|
|
@ -9,9 +9,13 @@ import type {
|
|||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
import {
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
|
|||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
|
||||
if (!id) return previous;
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
|
||||
if (!previous.has(id)) return previous;
|
||||
const next = new Set(previous);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
|
||||
|
||||
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
|
||||
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
|
||||
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
|
||||
return {
|
||||
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
|
||||
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
|
||||
};
|
||||
} catch {
|
||||
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
function saveOwnership(
|
||||
workOrderId: string | number | undefined,
|
||||
ownership: MediaSurfaceOwnership,
|
||||
): void {
|
||||
if (workOrderId == null) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
|
||||
JSON.stringify({
|
||||
completion: [...ownership.completionOwnedIds],
|
||||
extra: [...ownership.extraOwnedIds],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOwnershipState(loadOwnership(workOrderId));
|
||||
}, [workOrderId]);
|
||||
|
||||
const updateOwnership = useCallback(
|
||||
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
|
||||
setOwnershipState((current) => {
|
||||
const next = update(current);
|
||||
saveOwnership(workOrderId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
const item = await uploadMediaMutation.mutateAsync(input);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
);
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() =>
|
||||
uploads.filter(
|
||||
(item) =>
|
||||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||
item.uploadStatus === "uploading" ||
|
||||
item.uploadStatus === "failed",
|
||||
),
|
||||
[uploads],
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||
[uploads, ownership],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||
),
|
||||
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||
isExtrasSurfaceMedia(item, ownership),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
[detailForRow?.media, ownership],
|
||||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
|
|
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
|
|||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
|
|
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
export function buildInlineDraftCreateInput(
|
||||
draft: WorkOrderTableDraft,
|
||||
|
|
@ -36,13 +37,19 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: draft.status || "Incomplete",
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
additionalContacts: draft.additionalContacts ?? [],
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
scheduleEnd: draft.scheduleEnd || "",
|
||||
schedMode: draft.schedMode,
|
||||
isAddOnOverride: draft.isAddOnOverride,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
|
|
@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
{selected ? (
|
||||
<DispatcherPill user={selected} />
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<Users
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
Select dispatcher
|
||||
<span className="truncate whitespace-nowrap">Select dispatcher</span>
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react";
|
||||
import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type DateCellValueArgs = {
|
||||
showRange: boolean;
|
||||
showWeek: boolean;
|
||||
value: string;
|
||||
scheduleEnd: string;
|
||||
targetWeek: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export function DateCellValue({
|
||||
showRange,
|
||||
showWeek,
|
||||
value,
|
||||
scheduleEnd,
|
||||
targetWeek,
|
||||
q,
|
||||
highlight,
|
||||
}: DateCellValueArgs) {
|
||||
if (showRange) {
|
||||
return (
|
||||
<>
|
||||
<CalendarRange size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
{fmtDateRange(value, scheduleEnd)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (showWeek) {
|
||||
return (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
|
||||
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
|
||||
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
|
||||
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type DateCellProps = {
|
||||
value: string;
|
||||
|
|
@ -15,6 +18,9 @@ type DateCellProps = {
|
|||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
onSaveRange?: (start: string, end: string) => void;
|
||||
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||
onClear?: () => void;
|
||||
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
|
||||
|
|
@ -30,14 +36,34 @@ export function DateCell({
|
|||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
scheduleEnd = "",
|
||||
schedMode,
|
||||
onSaveRange,
|
||||
onClear,
|
||||
srStatusSuffix,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
|
||||
const open = Boolean(anchorEl);
|
||||
const allowRange = Boolean(allowWeekMode && onSaveRange);
|
||||
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
|
||||
{
|
||||
open,
|
||||
value,
|
||||
scheduleEnd,
|
||||
allowRange,
|
||||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
},
|
||||
);
|
||||
const showWeek = allowWeekMode && !value && !!targetWeek;
|
||||
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
||||
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
|
||||
const modes: WizardSchedMode[] = allowRange
|
||||
? ["week", "date", "range"]
|
||||
: allowWeekMode
|
||||
? ["week", "date"]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -60,29 +86,15 @@ export function DateCell({
|
|||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
{showWeek ? (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DateCellValue
|
||||
showRange={showRange}
|
||||
showWeek={showWeek}
|
||||
value={value}
|
||||
scheduleEnd={scheduleEnd}
|
||||
targetWeek={targetWeek}
|
||||
q={q}
|
||||
highlight={highlight}
|
||||
/>
|
||||
</span>
|
||||
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
|
||||
<ChevronDown
|
||||
|
|
@ -92,36 +104,35 @@ export function DateCell({
|
|||
/>
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{Boolean(allowWeekMode) && (
|
||||
<div className="mb-2.5 flex items-center gap-1.5">
|
||||
{(["week", "date"] as const).map((m) => {
|
||||
const active = cellMode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setCellMode(m)}
|
||||
className="rounded-full border px-3 text-[11.5px] font-medium"
|
||||
style={{
|
||||
height: 26,
|
||||
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{m === "week" ? "Week" : "Specific date"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
allowRange={allowRange}
|
||||
onSelect={setCellMode}
|
||||
/>
|
||||
{allowWeekMode && cellMode === "week" ? (
|
||||
<WeekPicker
|
||||
selected={!value ? targetWeek : ""}
|
||||
|
|
@ -130,6 +141,16 @@ export function DateCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : allowRange && cellMode === "range" ? (
|
||||
<WizardDateRangePanel
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
onRangeClick={handleRangeClick}
|
||||
onDone={(start, end) => {
|
||||
onSaveRange?.(start, end);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
|
|
@ -139,21 +160,6 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ type EditableWONumberCellProps = {
|
|||
carriedOver?: number;
|
||||
isAddOn?: boolean;
|
||||
originalDate?: string;
|
||||
originalWeek?: string;
|
||||
scheduledOn?: string;
|
||||
status?: string;
|
||||
q?: string;
|
||||
currentId: string | number;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
|
@ -37,6 +40,9 @@ export function EditableWONumberCell({
|
|||
carriedOver = 0,
|
||||
isAddOn = false,
|
||||
originalDate,
|
||||
originalWeek,
|
||||
scheduledOn,
|
||||
status,
|
||||
q,
|
||||
currentId,
|
||||
allOrders,
|
||||
|
|
@ -118,7 +124,13 @@ export function EditableWONumberCell({
|
|||
</span>
|
||||
<AddOnPill when={isAddOn} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
<CarriedOverCounterPill
|
||||
count={carriedOver}
|
||||
originalWeek={originalWeek}
|
||||
originalDate={originalDate}
|
||||
scheduledOn={scheduledOn}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -155,7 +167,13 @@ export function EditableWONumberCell({
|
|||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
<CarriedOverCounterPill
|
||||
count={carriedOver}
|
||||
originalWeek={originalWeek}
|
||||
originalDate={originalDate}
|
||||
scheduledOn={scheduledOn}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||
>
|
||||
Severity (from APM)
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { useState } from "react";
|
|||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||
|
|
@ -64,7 +68,7 @@ function PastDueStatusCell({
|
|||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
title={PAST_DUE_BADGE_TOOLTIP}
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -97,7 +101,7 @@ function PastDueStatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
{PAST_DUE_POPOVER_BODY}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -108,7 +112,7 @@ function PastDueStatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
{PAST_DUE_RESCHEDULE_CTA}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,6 +128,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -162,22 +167,19 @@ export function StatusCell({
|
|||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
{statusOptions.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
disabled={option.disabled}
|
||||
title={option.title}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
|
|
@ -189,9 +191,9 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
option.disabled
|
||||
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
|
|
|||
|
|
@ -18,14 +18,32 @@ type TypeCellProps = {
|
|||
value: WOType | "";
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
||||
if (value) {
|
||||
return <WoTableTypeLabel type={value} q={q} />;
|
||||
}
|
||||
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||
}
|
||||
|
||||
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
const [resetSeverity, setResetSeverity] = useState(false);
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pick(t: WOType) {
|
||||
setAnchorEl(null);
|
||||
|
|
@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
|||
onSave(t, needsSeverity ? severity : null);
|
||||
}
|
||||
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||
title={value ? undefined : "Select type of WO"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{value ? (
|
||||
<WoTableTypeLabel type={value} q={q} />
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
|
|
@ -76,6 +84,10 @@ export function TypeSeverityModal({
|
|||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: 1,
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
|
|
@ -83,6 +95,9 @@ export function TypeSeverityModal({
|
|||
}}
|
||||
>
|
||||
Type & severity
|
||||
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -93,6 +108,10 @@ export function TypeSeverityModal({
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||
>
|
||||
Type
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WO_TYPES.map((t) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
type UseVendorDialogFormArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
vendorId: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedCompany, setSelectedCompany] = useState(company);
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const companyOptions = useMemo(
|
||||
() => buildCompanyOptions(allVendors, company),
|
||||
[allVendors, company],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, selectedCompany, tech, company),
|
||||
[allVendors, selectedCompany, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
|
||||
setSelectedCompany(company);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, initialOptions));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// invariant: rebuilding options after Add must not clear staged technician drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedCompany;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company: selectedCompany,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const canSubmit = computeVendorDialogCanSubmit({
|
||||
current: {
|
||||
company: selectedCompany,
|
||||
selectedKey,
|
||||
phone,
|
||||
notes,
|
||||
pendingCreateCount: Object.keys(pendingCreates).length,
|
||||
},
|
||||
baseline: {
|
||||
company,
|
||||
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
phone:
|
||||
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
|
||||
notes: "",
|
||||
pendingCreateCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
});
|
||||
|
||||
return {
|
||||
companyOptions,
|
||||
technicianOptions,
|
||||
selected,
|
||||
t,
|
||||
phone,
|
||||
notes,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
selectedCompany,
|
||||
selectedKey,
|
||||
anchorCompanyName,
|
||||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
setSelectedCompany(nextCompany);
|
||||
if (selectedOption?.company !== nextCompany) {
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSelectedKey("");
|
||||
setT("");
|
||||
setPhone("");
|
||||
}
|
||||
},
|
||||
handlePick: (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
},
|
||||
handleTechnicianCreated: (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
},
|
||||
setPhone,
|
||||
setNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
|
|||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
|||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
|||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
|||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||
techPhone: selectedKey ? phone : "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import { Building2, Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
|
|||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import type {
|
||||
CompanyOption,
|
||||
TechnicianOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedCompany: string;
|
||||
companyOptions: CompanyOption[];
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
|||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onCompanyPick: (company: string) => void;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
|||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedCompany,
|
||||
companyOptions,
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
|||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onCompanyPick,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
|||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<FieldLabel required>Company</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedCompany}
|
||||
placeholder="Select company …"
|
||||
icon={
|
||||
<Building2
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search company…"
|
||||
options={companyOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={onCompanyPick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company info</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Technician (optional)</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||
disabled={!selectedCompany}
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
label: option.name,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
|||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldLabel>Notes (optional)</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
export type VendorDialogSubmitSnapshot = {
|
||||
company: string;
|
||||
selectedKey: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
pendingCreateCount: number;
|
||||
};
|
||||
|
||||
type VendorDialogCanSubmitInput = {
|
||||
current: VendorDialogSubmitSnapshot;
|
||||
baseline: VendorDialogSubmitSnapshot;
|
||||
};
|
||||
|
||||
export function computeVendorDialogCanSubmit({
|
||||
current,
|
||||
baseline,
|
||||
}: VendorDialogCanSubmitInput): boolean {
|
||||
const valid = current.company.trim() !== "";
|
||||
const hasNewTechs = current.pendingCreateCount > 0;
|
||||
const dirty =
|
||||
current.company !== baseline.company ||
|
||||
current.selectedKey !== baseline.selectedKey ||
|
||||
current.phone !== baseline.phone ||
|
||||
current.pendingCreateCount !== baseline.pendingCreateCount;
|
||||
return valid && (dirty || hasNewTechs);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { ExternalLink, Mail, MapPin } from "lucide-react";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||
{Boolean(vendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||
{vendor != null && Boolean(vendor.address) && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
viewOnly?: boolean;
|
||||
canSubmit?: boolean;
|
||||
};
|
||||
|
||||
const lookDisabledSx = {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({
|
||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
|||
onCancel,
|
||||
onSave,
|
||||
viewOnly = false,
|
||||
canSubmit = false,
|
||||
}: VendorDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={isPending}
|
||||
onClick={() => void onSave()}
|
||||
aria-disabled={!canSubmit && !isPending}
|
||||
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
|
||||
sx={canSubmit || isPending ? undefined : lookDisabledSx}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void onSave();
|
||||
}}
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
|||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export type CompanyOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export function buildCompanyOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
currentCompany: string,
|
||||
): CompanyOption[] {
|
||||
const companies = new Map<string, CompanyOption>();
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const companyName = vendor.companyName.trim();
|
||||
if (companyName && !companies.has(companyName)) {
|
||||
companies.set(companyName, {
|
||||
key: companyName,
|
||||
label: companyName,
|
||||
vendor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCompany && !companies.has(currentCompany)) {
|
||||
companies.set(currentCompany, {
|
||||
key: currentCompany,
|
||||
label: currentCompany,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(companies.values());
|
||||
}
|
||||
|
||||
export function buildTechnicianOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
selectedCompany: string,
|
||||
tech: string,
|
||||
company: string,
|
||||
currentCompany: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.companyName !== selectedCompany) {
|
||||
continue;
|
||||
}
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: String(vendor.id),
|
||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
|||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
if (tech && selectedCompany === currentCompany) {
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||
vendors.find((v) => v.contactName === tech);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
label: tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
company: currentCompany,
|
||||
phone: "",
|
||||
vendor,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
|||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, tech, company),
|
||||
[allVendors, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const handlePick = (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
};
|
||||
|
||||
const handleTechnicianCreated = (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
};
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
const form = useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
<Text as="p" className="sr-only">
|
||||
Search and select the technician
|
||||
Search and select the vendor company
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
selectedCompany={form.selectedCompany}
|
||||
companyOptions={form.companyOptions}
|
||||
selectedKey={form.selectedKey}
|
||||
technicianOptions={form.technicianOptions}
|
||||
anchorCompanyName={form.anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||
tech={form.selected?.name ?? form.t}
|
||||
phone={form.phone}
|
||||
notes={form.notes}
|
||||
resolvedCompany={form.resolvedCompany}
|
||||
selectedVendor={form.selectedVendor}
|
||||
saveError={form.saveError}
|
||||
onCompanyPick={form.handleCompanyPick}
|
||||
onPick={form.handlePick}
|
||||
onPhoneChange={form.setPhone}
|
||||
onNotesChange={form.setNotes}
|
||||
onTechnicianCreated={form.handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
isPending={form.isPending}
|
||||
canSubmit={form.canSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
onSave={form.handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { RefreshCw } from "lucide-react";
|
||||
import { Tooltip } from "@mui/material";
|
||||
import {
|
||||
formatCarriedOverHover,
|
||||
resolveCarryOverSourceWeek,
|
||||
shouldShowCarriedOverPill,
|
||||
} from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
|
||||
|
||||
const RESCHEDULE_PILL_STYLE = {
|
||||
|
|
@ -46,17 +51,30 @@ export function RescheduleCounterPill({
|
|||
);
|
||||
}
|
||||
|
||||
export function CarriedOverCounterPill({ count }: { count: number }) {
|
||||
if (count < 2) {
|
||||
export function CarriedOverCounterPill({
|
||||
count,
|
||||
originalWeek,
|
||||
originalDate,
|
||||
scheduledOn,
|
||||
status,
|
||||
}: {
|
||||
count: number;
|
||||
originalWeek?: string;
|
||||
originalDate?: string;
|
||||
scheduledOn?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (!shouldShowCarriedOverPill(count, status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = `Carried over ${count} times`;
|
||||
const sourceWeek = resolveCarryOverSourceWeek({ originalWeek, originalDate, scheduledOn });
|
||||
const hover = formatCarriedOverHover(count, sourceWeek);
|
||||
|
||||
return (
|
||||
<Tooltip title="Carried over" arrow placement="top">
|
||||
<Tooltip title={hover} arrow placement="top">
|
||||
<span
|
||||
aria-label={label}
|
||||
aria-label={hover}
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={MUTED_PILL_STYLE}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
|||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
function showRescheduleToast(
|
||||
message: string,
|
||||
onNavigate: () => void,
|
||||
linkLabel = "Click to view it.",
|
||||
) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
linkLabel,
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
|||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
if (!weekStart) {
|
||||
return showRescheduleToast(
|
||||
"Work order moved to Unscheduled.",
|
||||
onNavigate,
|
||||
"View in Advanced Filters.",
|
||||
);
|
||||
}
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
formatSeverityEscalationToast,
|
||||
shouldAnnounceSeverityEscalation,
|
||||
} from "@/domain/work-orders/utils/severity-escalation";
|
||||
|
||||
type PersistPatchFn = (
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
options?: { onSuccess?: () => void },
|
||||
) => void;
|
||||
|
||||
export function saveExistingRowType(
|
||||
onPatch: PersistPatchFn,
|
||||
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
|
||||
type: WOType,
|
||||
severity: WorkOrderSeverity | null,
|
||||
) {
|
||||
const patch = { type, severity };
|
||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||
onPatch(row.id, patch);
|
||||
return;
|
||||
}
|
||||
onPatch(row.id, patch, {
|
||||
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||
});
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
dispatcherId: draft.dispatcherId,
|
||||
dispatcherName: "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
scheduleEnd: draft.scheduleEnd,
|
||||
schedMode: draft.schedMode,
|
||||
vendorId: draft.vendorId,
|
||||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
applyScheduleRangePatch,
|
||||
type WorkOrderTablePatchFn,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
|
|
@ -9,6 +10,27 @@ import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
|||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
||||
function revealOrNotifyScheduledDay(args: {
|
||||
id: string | number;
|
||||
date: string;
|
||||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
revealScheduledRow: (id: string | number) => void;
|
||||
}) {
|
||||
const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args;
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
}
|
||||
|
||||
type UseWoTableRowHandlersOptions = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
weekMonday: string;
|
||||
|
|
@ -81,29 +103,45 @@ export function useWoTableRowHandlers({
|
|||
},
|
||||
onScheduleDate: (id: string | number, date: string) => {
|
||||
onPatch(id, applySchedulePatch(date, false));
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => {
|
||||
onPatch(id, applyScheduleRangePatch(start, end));
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date: start,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const isClear = week === "";
|
||||
onPatch(
|
||||
id,
|
||||
isClear
|
||||
? applySchedulePatch("", false)
|
||||
: {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
},
|
||||
);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(week);
|
||||
if (!isClear) {
|
||||
onNavigateToDay(week);
|
||||
}
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
|
|||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
|
|
@ -74,7 +78,8 @@ function InlineScheduleOnCell({
|
|||
weekDefault: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell
|
||||
|
|
@ -83,31 +88,57 @@ function InlineScheduleOnCell({
|
|||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(date) =>
|
||||
onPatch({
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -215,6 +246,7 @@ export function WoTableInlineRowCells({
|
|||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { saveExistingRowType } from "./save-existing-row-type";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
|
|
@ -40,6 +43,9 @@ export function WoTableRowCells({
|
|||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
const hasSchedule =
|
||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -82,6 +88,9 @@ export function WoTableRowCells({
|
|||
carriedOver={row.carriedOver}
|
||||
isAddOn={row.isAddOn}
|
||||
originalDate={row.originalDate}
|
||||
originalWeek={row.originalWeek}
|
||||
scheduledOn={row.scheduledOn}
|
||||
status={row.status}
|
||||
q={search}
|
||||
currentId={row.id}
|
||||
allOrders={allRows}
|
||||
|
|
@ -99,7 +108,8 @@ export function WoTableRowCells({
|
|||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -111,9 +121,7 @@ export function WoTableRowCells({
|
|||
dispatcherId={row.dispatcherId}
|
||||
users={users}
|
||||
highlight={!row.dispatcherId}
|
||||
onSave={(id, name) =>
|
||||
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
|
||||
}
|
||||
onSave={(id, name) => applyAssignedToTableSave(handlers.onPatch, row.id, id, name)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -127,8 +135,12 @@ export function WoTableRowCells({
|
|||
highlight={forceScheduleHighlight}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
scheduleEnd={row.scheduleEnd}
|
||||
schedMode={row.schedMode}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
|
||||
type WoTableRowReorderHandleProps = {
|
||||
disabled: boolean;
|
||||
isDragging: boolean;
|
||||
woNumber: string;
|
||||
onArrowReorder: (delta: -1 | 1) => void;
|
||||
};
|
||||
|
||||
export function WoTableRowReorderHandle({
|
||||
disabled,
|
||||
isDragging,
|
||||
woNumber,
|
||||
onArrowReorder,
|
||||
}: WoTableRowReorderHandleProps) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
|
|
@ -7,6 +6,7 @@ import type {
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import {
|
||||
canApplyWorkOrderTablePatch,
|
||||
canMutateWorkOrderFlag,
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { FlagCell } from "./cells/flag-cell";
|
||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||
import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle";
|
||||
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
|
||||
import {
|
||||
MUTATION_CELL_STYLE,
|
||||
|
|
@ -37,6 +38,7 @@ export type WoTableRowHandlers = {
|
|||
) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
onScheduleWeek: (id: string | number, week: string) => void;
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -65,6 +67,31 @@ type WoTableRowProps = {
|
|||
handlers: WoTableRowHandlers;
|
||||
};
|
||||
|
||||
function guardScheduleHandlers(
|
||||
row: WorkOrderTableRow,
|
||||
handlers: WoTableRowHandlers,
|
||||
): WoTableRowHandlers {
|
||||
return {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
onScheduleRange: (id, start, end) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
|
||||
handlers.onScheduleRange(id, start, end);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function WoTableRow({
|
||||
row,
|
||||
allRows,
|
||||
|
|
@ -87,21 +114,7 @@ export function WoTableRow({
|
|||
const isCoreLocked = isWorkOrderCoreLocked(row.status);
|
||||
const closedForEdit = isCanceled || isCoreLocked;
|
||||
|
||||
const guardedHandlers: WoTableRowHandlers = {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
};
|
||||
const guardedHandlers = guardScheduleHandlers(row, handlers);
|
||||
|
||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
|
|
@ -155,15 +168,21 @@ export function WoTableRow({
|
|||
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
<WoTableRowReorderHandle
|
||||
disabled={isCanceled}
|
||||
isDragging={isDragging}
|
||||
woNumber={row.woNumber}
|
||||
onArrowReorder={(delta) => {
|
||||
const overId = neighborIdForReorder(
|
||||
allRows,
|
||||
row,
|
||||
delta,
|
||||
(item) => !isWorkOrderFullyLocked(item.status),
|
||||
);
|
||||
if (overId == null) return;
|
||||
handlers.onReorder(row.id, overId);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<FlagCell
|
||||
|
|
@ -182,7 +201,7 @@ export function WoTableRow({
|
|||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
@ -193,7 +212,7 @@ export function WoTableRow({
|
|||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
|
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
|
|||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
|
|
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
||||
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle
|
||||
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Photos & Videos
|
||||
</DialogTitle>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
Upload photos and videos for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
categorizeAfterUpload?: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
categorizeAfterUpload = false,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
|
|
@ -27,7 +29,7 @@ export function MediaUploaderDropzone({
|
|||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
|
|||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const managesPurpose = Boolean(onCategorize);
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
managesPurpose;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
|
|
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
|
|||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
{needsCategory && (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
|
|
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
)}
|
||||
{Boolean(!needsCategory && managesPurpose) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ export function MediaUploader({
|
|||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = {
|
|||
allowWeekMode?: boolean;
|
||||
targetWeek: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
defaultMode?: "week" | "date";
|
||||
};
|
||||
|
||||
function resolveInitialMode({
|
||||
|
|
@ -17,9 +18,10 @@ function resolveInitialMode({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
}: Pick<
|
||||
UseWizardDateFieldStateArgs,
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode"
|
||||
>): WizardSchedMode {
|
||||
if (allowRange && schedMode === "range") {
|
||||
return "range";
|
||||
|
|
@ -27,6 +29,9 @@ function resolveInitialMode({
|
|||
if (allowWeekMode && !value && targetWeek) {
|
||||
return "week";
|
||||
}
|
||||
if (allowWeekMode && defaultMode) {
|
||||
return defaultMode;
|
||||
}
|
||||
return "date";
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +44,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
}: UseWizardDateFieldStateArgs) {
|
||||
const initialMode = resolveInitialMode({
|
||||
allowRange,
|
||||
|
|
@ -46,6 +52,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
});
|
||||
|
||||
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
|
|
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
onClear={
|
||||
hasWizardSchedule(draft)
|
||||
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
TIME_SLOTS,
|
||||
WIZARD_STATUS_DOTS,
|
||||
type WizardWOStatus,
|
||||
|
|
@ -15,12 +13,15 @@ import {
|
|||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
getStatusOptionState,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
|
|||
);
|
||||
}
|
||||
|
||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||
return ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
|
||||
return CREATION_INLINE_STATUSES.map((status) => {
|
||||
const option = getStatusOptionState(draft, status, closabilityGaps);
|
||||
return {
|
||||
key: status,
|
||||
label: status,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
|
||||
disabled: isCompletedBlocked,
|
||||
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
|
||||
disabled: option.disabled,
|
||||
title: option.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -192,7 +193,6 @@ export function WizardStepVendorTime({
|
|||
onUpdate,
|
||||
onPatch,
|
||||
}: WizardStepVendorTimeProps) {
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
|
|
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
|
|||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
options={buildWizardStatusOptions(draft, closabilityGaps)}
|
||||
onPick={(status) => {
|
||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||
if (status === "Completed" && draft.status !== "Completed") {
|
||||
setConfirmComplete(true);
|
||||
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
onUpdate("status", status as WizardWOStatus);
|
||||
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { getAuthUser } from "@/lib/auth/auth-storage";
|
||||
|
||||
export function resolveBoardRowOrderOwnerId(): string {
|
||||
const user = getAuthUser();
|
||||
return user?.id == null ? "" : String(user.id);
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id";
|
||||
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view";
|
||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
pickBoardPager,
|
||||
pageSelectedDayGroups,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import {
|
||||
filterByBarAndSearch,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination";
|
||||
import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
|
||||
|
|
@ -33,12 +21,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
useWorkOrdersBoardSource(filters);
|
||||
|
||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||
const ownerId = resolveBoardRowOrderOwnerId();
|
||||
|
||||
const local = useWorkOrdersTableLocalState({
|
||||
items: weeklyItems,
|
||||
weekMonday: filters.weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId,
|
||||
});
|
||||
const { displayRows, ...localUi } = local;
|
||||
|
||||
|
|
@ -54,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
const dayView = useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed: local.triggerFlash,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
|
||||
[displayRows, local.selectedId],
|
||||
);
|
||||
|
||||
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
|
||||
const scopeTotalCount = advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: dayView.dayTotalCount;
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: advActive,
|
||||
ready: !isLoading,
|
||||
|
|
@ -113,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
setPage: filters.setPage,
|
||||
});
|
||||
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
|
||||
const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount;
|
||||
const pager = pickBoardPager({
|
||||
advActive,
|
||||
isLoading,
|
||||
noSearchResults,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
advResultCount: advResults.length,
|
||||
search: {
|
||||
page: filters.page,
|
||||
|
|
@ -125,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
},
|
||||
day: {
|
||||
pageSize: pagination.pageSize,
|
||||
pageIndex: pagedDay.pageIndex,
|
||||
pageCount: pagedDay.pageCount,
|
||||
itemCount: dayTotalCount,
|
||||
footerLabel: displayGroups[0]?.label ?? "",
|
||||
pageSize: dayView.pagination.pageSize,
|
||||
pageIndex: dayView.pagedDay.pageIndex,
|
||||
pageCount: dayView.pagedDay.pageCount,
|
||||
itemCount: dayView.dayTotalCount,
|
||||
footerLabel: dayView.displayGroups[0]?.label ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -145,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
unassignedTotalCount: 0,
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
unscheduled: dayView.unscheduled,
|
||||
displayGroups: dayView.displayGroups,
|
||||
searchActive: dayView.searchActive,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
isEmptyWeek: dayView.isEmptyWeek,
|
||||
todayKey: dayView.todayKey,
|
||||
...localUi,
|
||||
selectedRow,
|
||||
scopeTotalCount,
|
||||
|
|
@ -167,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
filters.setPageSize(next);
|
||||
return;
|
||||
}
|
||||
pagination.setPageSize(next);
|
||||
dayView.pagination.setPageSize(next);
|
||||
},
|
||||
goToPrevDayPage: advActive
|
||||
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
|
||||
: pagination.goToPrevPage,
|
||||
: dayView.pagination.goToPrevPage,
|
||||
goToNextDayPage: advActive
|
||||
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
|
||||
: pagination.goToNextPage,
|
||||
revealScheduledRow: pagination.revealRow,
|
||||
: dayView.pagination.goToNextPage,
|
||||
revealScheduledRow: dayView.pagination.revealRow,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
export type UseWorkOrdersTableDayViewParams = {
|
||||
filters: FiltersReturn;
|
||||
advActive: boolean;
|
||||
filtered: WorkOrderTableRow[];
|
||||
advResults: WorkOrderTableRow[];
|
||||
onRevealed: (id: string | number) => void;
|
||||
};
|
||||
|
||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||
|
||||
export function useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed,
|
||||
}: UseWorkOrdersTableDayViewParams) {
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
onRevealed,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduled,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
dayTotalCount,
|
||||
dayVisibleCount,
|
||||
pagination,
|
||||
pagedDay,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change";
|
||||
import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import {
|
||||
applyRowOrderByDay,
|
||||
boardRowOrderStorageKey,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
type BoardRowOrderByDay,
|
||||
|
|
@ -23,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
|
|||
weekMonday: string;
|
||||
advActive: boolean;
|
||||
isFetching: boolean;
|
||||
ownerId?: string;
|
||||
};
|
||||
|
||||
function usePrunePatchesOnFetchSettle(
|
||||
|
|
@ -44,6 +49,7 @@ export function useWorkOrdersTableLocalState({
|
|||
weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId = "",
|
||||
}: UseWorkOrdersTableLocalStateParams) {
|
||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||
|
|
@ -51,8 +57,19 @@ export function useWorkOrdersTableLocalState({
|
|||
const ui = useWorkOrdersTableUiState(weekMonday);
|
||||
|
||||
useEffect(() => {
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday));
|
||||
}, [weekMonday]);
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
|
||||
}, [ownerId, weekMonday]);
|
||||
|
||||
useEffect(() => {
|
||||
const storageKey = boardRowOrderStorageKey(weekMonday, ownerId);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== storageKey) return;
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
|
||||
toast.info("Board order was updated in another session. Refreshed.");
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [ownerId, weekMonday]);
|
||||
|
||||
const apiRows = useMemo(
|
||||
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||
|
|
@ -62,8 +79,11 @@ export function useWorkOrdersTableLocalState({
|
|||
const displayRows = useMemo(() => {
|
||||
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
|
||||
}, [apiRows, localRows, rowOrderByDay]);
|
||||
const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) =>
|
||||
applyCarryOverDisplayDayFields(row, weekMonday),
|
||||
);
|
||||
return applyRowOrderByDay(merged, rowOrderByDay);
|
||||
}, [apiRows, localRows, rowOrderByDay, weekMonday]);
|
||||
|
||||
const clearRowPatch = useCallback((id: string | number) => {
|
||||
const key = String(id);
|
||||
|
|
@ -100,17 +120,17 @@ export function useWorkOrdersTableLocalState({
|
|||
|
||||
if (patch.scheduledOn === undefined || advActive) return;
|
||||
|
||||
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = removeIdFromRowOrder(prev, key);
|
||||
if (next === prev) return prev;
|
||||
saveBoardRowOrder(weekMonday, next);
|
||||
return next;
|
||||
});
|
||||
setRowOrderByDay((prev) =>
|
||||
applyBoardRowOrderOnScheduleChange(prev, {
|
||||
weekMonday,
|
||||
ownerId,
|
||||
id: key,
|
||||
scheduledOn: patch.scheduledOn!,
|
||||
previousDayGroup,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[advActive, displayRows, weekMonday, localRows],
|
||||
[advActive, displayRows, localRows, ownerId, weekMonday],
|
||||
);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
|
|
@ -120,6 +140,7 @@ export function useWorkOrdersTableLocalState({
|
|||
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
|
||||
if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return;
|
||||
|
||||
const dayGroup = dragRow.dayGroup;
|
||||
const dayIds = displayRows
|
||||
|
|
@ -130,11 +151,11 @@ export function useWorkOrdersTableLocalState({
|
|||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
||||
if (!advActive) saveBoardRowOrder(weekMonday, next);
|
||||
if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[advActive, displayRows, weekMonday],
|
||||
[advActive, displayRows, ownerId, weekMonday],
|
||||
);
|
||||
|
||||
const { triggerFlash } = ui;
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { HTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
|
|
@ -91,7 +91,12 @@ export const upliftsApi = {
|
|||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await api.get(evidenceUrl(id));
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api, apiGet, apiPut } from "@/api/api";
|
||||
import { apiGet, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import type {
|
||||
SiteVendorPreference,
|
||||
VendorAssignmentResult,
|
||||
|
|
@ -27,7 +27,13 @@ type InsightParams = {
|
|||
};
|
||||
|
||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
`${base}/${path}`,
|
||||
{ searchParams: searchParams(params) },
|
||||
"vendorOperationsApi.saveDownload",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import type {
|
||||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -38,30 +39,45 @@ function uploadVendorDocument({
|
|||
purpose,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
});
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
|
||||
entryPoint: "vendorPortalApi.uploadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
({ reportStatus }) =>
|
||||
new Promise<VendorPortalDocument>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
reportStatus(request.status);
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(
|
||||
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reportStatus(request.status);
|
||||
reject(new Error("Upload failed"));
|
||||
});
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadVendorDocument(
|
||||
|
|
@ -69,19 +85,32 @@ async function downloadVendorDocument(
|
|||
dispatchId: string | number,
|
||||
document: VendorPortalDocument,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
await traceHttpOperation(
|
||||
{
|
||||
method: "GET",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
entryPoint: "vendorPortalApi.downloadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
);
|
||||
reportStatus(response.status);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Download failed",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
|
|
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
|
|||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
return traceHttpOperation<T>(
|
||||
{
|
||||
method: options?.method ?? "GET",
|
||||
url: resolveVendorPortalUrl(path),
|
||||
entryPoint: "vendorPortalApi.call",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
reportStatus(response.status);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isNoApprovalRequired(payload: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
|
|
@ -33,10 +35,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||
function throwDocumentHttpError(status: number, data: unknown): never {
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
if (status === 409) {
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
|
|
@ -45,14 +47,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
|
|||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
if (status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
throw new ApiError(mapHttpStatusToMessage(status, data), status);
|
||||
}
|
||||
|
||||
export const workOrderBoardDocumentsApi = {
|
||||
|
|
@ -81,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
commentId: string | number,
|
||||
text: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||
json: { text },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.commentItem(workOrderId, commentId),
|
||||
{ json: { text }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateComment",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -94,7 +98,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||
}
|
||||
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||
|
|
@ -119,21 +123,26 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
const { ok, status, data } = await traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: API_PATHS.workOrder.media(workOrderId),
|
||||
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const result = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
);
|
||||
reportStatus(result.status);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
if (ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(status, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
|
|
@ -152,15 +161,17 @@ export const workOrderBoardDocumentsApi = {
|
|||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
formData.append("workOrderVersion", workOrderVersion);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateMediaCategory",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (
|
||||
|
|
@ -183,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
);
|
||||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
searchParams: { workOrderVersion },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"delete",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
|
||||
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.deleteMedia",
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
|
|
@ -196,7 +209,30 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
getMediaContent: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(mapHttpStatusToMessage(404), 404);
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
|
|
@ -216,14 +252,16 @@ export const workOrderBoardDocumentsApi = {
|
|||
}
|
||||
|
||||
const formData = buildCompletionDocFormData(params);
|
||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.completionDoc(workOrderId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.uploadCompletionDoc",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
|
|
@ -314,10 +314,12 @@ export const workOrdersApi = {
|
|||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
|||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** why: backend field names must never be exposed through user-facing board toasts. */
|
||||
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
||||
if (error.code === "DispatchRequired") {
|
||||
return "Select a vendor company before saving.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
||||
import type {
|
||||
FileCategory,
|
||||
MediaCategory,
|
||||
|
|
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
|||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||
category: mapMediaCategory(item.category ?? item.Category),
|
||||
url: readString(item, "url", "Url"),
|
||||
fileName: readString(item, "fileName", "FileName") || "file",
|
||||
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||
"isAddOn",
|
||||
"primaryDispatchStatus",
|
||||
/** Client-only Schedule On range overlay — API persists range start as scheduledDate. */
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
]);
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
|
|
@ -84,7 +87,7 @@ export function buildBoardPatchPayload(
|
|||
if (row.dispatchRowVersion) {
|
||||
payload.dispatchVersion = row.dispatchRowVersion;
|
||||
}
|
||||
if (row.primaryDispatchId != null) {
|
||||
if (row.primaryDispatchId != null && row.primaryDispatchId > 0) {
|
||||
payload.primaryDispatchId = row.primaryDispatchId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
import type { UpliftSummary } from "./work-order-uplift";
|
||||
|
||||
|
|
@ -65,6 +65,10 @@ export interface WorkOrderTableRow {
|
|||
originalDate: string;
|
||||
isPastDue: boolean;
|
||||
scheduleWeekOnly: boolean;
|
||||
/** Optimistic range end; API persists only `scheduledOn` (range start). */
|
||||
scheduleEnd?: string;
|
||||
/** Last Schedule On mode used in this session. */
|
||||
schedMode?: WizardSchedMode;
|
||||
mediaCount: number;
|
||||
title: string;
|
||||
priority: string;
|
||||
|
|
@ -105,6 +109,8 @@ export interface WorkOrderTableDraft {
|
|||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
scheduleWeekOnly: boolean;
|
||||
targetWeek: string;
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
/** Manual Add-On override when schedule is empty at inline create. */
|
||||
isAddOnOverride: boolean;
|
||||
}
|
||||
|
|
@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
additionalContacts: [],
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
boardPatchValidationMessage,
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
|
|
@ -19,9 +20,15 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import {
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export { maybeAutoSchedulePatch };
|
||||
|
||||
export type { WorkOrderTablePatch };
|
||||
|
||||
export type WorkOrderTablePatchOptions = {
|
||||
|
|
@ -124,10 +131,16 @@ export function useWorkOrderTableMutations(
|
|||
|
||||
const patchMutation = useMutation({
|
||||
meta: { suppressErrorToast: true },
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
mutationFn: ({
|
||||
id,
|
||||
persistPatch,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
|
|
@ -149,7 +162,7 @@ export function useWorkOrderTableMutations(
|
|||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
clearPatch(id);
|
||||
toast.error(error.message);
|
||||
toast.error(boardPatchValidationMessage(error));
|
||||
} else {
|
||||
clearPatch(id);
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
|
|
@ -158,15 +171,21 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
|
||||
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||
void patchMutation.mutateAsync({ id, patch }).then(
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
|
||||
() => options?.onSuccess?.(),
|
||||
(error: Error) => options?.onError?.(error),
|
||||
);
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
patchMutation.mutateAsync({ id, patch });
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
||||
};
|
||||
|
||||
return {
|
||||
patchField,
|
||||
|
|
@ -189,19 +208,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
|
|
@ -212,6 +221,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
targetWeek: scheduledOn,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
}
|
||||
|
|
@ -221,6 +232,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOn: false,
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
|
|
@ -229,6 +242,17 @@ export function applySchedulePatch(
|
|||
scheduledOn: trimmed,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
...deriveDayFields(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
|
||||
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
|
||||
return {
|
||||
...applySchedulePatch(start, false),
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import {
|
||||
moveIdToDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
saveBoardRowOrder,
|
||||
transferIdToDestinationWeekOrder,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export type ApplyBoardRowOrderOnScheduleChangeInput = {
|
||||
weekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
scheduledOn: string;
|
||||
previousDayGroup?: string;
|
||||
};
|
||||
|
||||
export function applyBoardRowOrderOnScheduleChange(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
input: ApplyBoardRowOrderOnScheduleChangeInput,
|
||||
): BoardRowOrderByDay {
|
||||
const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input;
|
||||
const nextDayGroup = deriveDayFields(scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
|
||||
return orderByDay;
|
||||
}
|
||||
|
||||
const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10));
|
||||
if (destinationWeekMonday !== weekMonday) {
|
||||
transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday: weekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
});
|
||||
return removeIdFromRowOrder(orderByDay, id);
|
||||
}
|
||||
|
||||
const next = moveIdToDayOrder(orderByDay, id, nextDayGroup);
|
||||
if (next !== orderByDay) {
|
||||
saveBoardRowOrder(weekMonday, next, ownerId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
export type AuditActionSource = Pick<
|
||||
|
|
@ -26,11 +27,51 @@ export type AuditActionRuleContext = {
|
|||
|
||||
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||
|
||||
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
function formatSeverityAuditLabel(raw: string): string {
|
||||
const n = Number(raw.trim());
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||
return SEVERITY_LEVELS[n].label;
|
||||
}
|
||||
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
|
||||
if (sevMatch) {
|
||||
return `SEV ${sevMatch[1]}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey === "type" ||
|
||||
fieldKey === "workordertype" ||
|
||||
fieldKey === "wotype" ||
|
||||
actionKey === "typechanged" ||
|
||||
actionKey.includes("workordertype");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
}
|
||||
|
||||
function tryStatusRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
if (oldValue.text) {
|
||||
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +160,8 @@ function tryAssignedToRule({
|
|||
/** Applied in order; the first non-null result wins. */
|
||||
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||
tryStatusRule,
|
||||
tryTypeRule,
|
||||
trySeverityRule,
|
||||
tryApptRule,
|
||||
tryScheduleRule,
|
||||
tryCarriedOverRule,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
|
|||
|
||||
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
|
||||
|
||||
export function boardRowOrderStorageKey(weekMonday: string): string {
|
||||
export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string {
|
||||
const owner = ownerId.trim() || "local";
|
||||
return `${STORAGE_PREFIX}${owner}:${weekMonday}`;
|
||||
}
|
||||
|
||||
export function legacyBoardRowOrderStorageKey(weekMonday: string): string {
|
||||
return `${STORAGE_PREFIX}${weekMonday}`;
|
||||
}
|
||||
|
||||
|
|
@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
|
|||
return Object.values(value).every(isStringArray);
|
||||
}
|
||||
|
||||
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function readStoredOrder(key: string): BoardRowOrderByDay {
|
||||
try {
|
||||
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
|||
}
|
||||
}
|
||||
|
||||
export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
|
||||
export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId));
|
||||
if (Object.keys(scoped).length) {
|
||||
return scoped;
|
||||
}
|
||||
return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday));
|
||||
}
|
||||
|
||||
export function saveBoardRowOrder(
|
||||
weekMonday: string,
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
ownerId = "",
|
||||
): void {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
|
||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay));
|
||||
} catch {
|
||||
// Quota exceeded or storage blocked — keep in-memory order only.
|
||||
}
|
||||
|
|
@ -105,6 +122,27 @@ export function applyRowOrderByDay<T extends { id: string | number; dayGroup: st
|
|||
});
|
||||
}
|
||||
|
||||
/** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */
|
||||
export function neighborIdForReorder<T extends { id: string | number; dayGroup: string }>(
|
||||
rows: T[],
|
||||
row: T,
|
||||
delta: -1 | 1,
|
||||
isReorderTarget: (item: T) => boolean = () => true,
|
||||
): string | number | null {
|
||||
const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup);
|
||||
const index = dayRows.findIndex((item) => String(item.id) === String(row.id));
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) {
|
||||
const candidate = dayRows[cursor];
|
||||
if (candidate && isReorderTarget(candidate)) {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||
const dragIdx = ids.indexOf(dragId);
|
||||
const overIdx = ids.indexOf(overId);
|
||||
|
|
@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
|
|||
return changed ? next : orderByDay;
|
||||
}
|
||||
|
||||
/** Drop the id from every day, then append it to the destination day. */
|
||||
export type TransferIdToDestinationWeekOrderInput = {
|
||||
sourceWeekMonday: string;
|
||||
destinationWeekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
nextDayGroup: string;
|
||||
};
|
||||
|
||||
/** Persist a moved row under the destination week when schedule crosses week boundaries. */
|
||||
export function transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
}: TransferIdToDestinationWeekOrderInput): void {
|
||||
if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) {
|
||||
return;
|
||||
}
|
||||
if (sourceWeekMonday === destinationWeekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId);
|
||||
const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id);
|
||||
if (sourceWithoutId !== sourceOrder) {
|
||||
saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId);
|
||||
}
|
||||
|
||||
const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId);
|
||||
saveBoardRowOrder(
|
||||
destinationWeekMonday,
|
||||
moveIdToDayOrder(destinationOrder, id, nextDayGroup),
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
|
||||
export function moveIdToDayOrder(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
id: string,
|
||||
nextDayGroup: string,
|
||||
): BoardRowOrderByDay {
|
||||
if (!nextDayGroup) {
|
||||
return removeIdFromRowOrder(orderByDay, id);
|
||||
}
|
||||
const without = removeIdFromRowOrder(orderByDay, id);
|
||||
const dest = without[nextDayGroup] ?? [];
|
||||
if (dest.includes(id)) {
|
||||
return without;
|
||||
}
|
||||
return { ...without, [nextDayGroup]: [...dest, id] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a visible day's new order into stored order: visible ids take the new sequence,
|
||||
* ids that were stored but not currently visible stay after the visible block (preserving
|
||||
|
|
|
|||
109
src/domain/work-orders/utils/carry-over-display.ts
Normal file
109
src/domain/work-orders/utils/carry-over-display.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const WEEKDAY_COUNT = 7;
|
||||
const FRIDAY_OFFSET = 4;
|
||||
|
||||
export type CarryOverRow = {
|
||||
scheduledOn: string;
|
||||
status?: string;
|
||||
carriedOver?: number;
|
||||
};
|
||||
|
||||
export function isTerminalCarryOverStatus(status?: string): boolean {
|
||||
return status === "Completed" || status === "Canceled";
|
||||
}
|
||||
|
||||
export function shouldShowCarriedOverPill(count: number, status?: string): boolean {
|
||||
return count >= 2 && !isTerminalCarryOverStatus(status);
|
||||
}
|
||||
|
||||
export function toIsoDate(value?: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const iso = value.slice(0, 10);
|
||||
return ISO_DATE.test(iso) ? iso : "";
|
||||
}
|
||||
|
||||
export function resolveCarryOverSourceWeek(input: {
|
||||
originalWeek?: string;
|
||||
originalDate?: string;
|
||||
scheduledOn?: string;
|
||||
}): string {
|
||||
const originalWeek = toIsoDate(input.originalWeek);
|
||||
if (originalWeek) {
|
||||
return mondayOf(originalWeek);
|
||||
}
|
||||
const originalDate = toIsoDate(input.originalDate);
|
||||
if (originalDate) {
|
||||
return mondayOf(originalDate);
|
||||
}
|
||||
const scheduledOn = toIsoDate(input.scheduledOn);
|
||||
return scheduledOn ? mondayOf(scheduledOn) : "";
|
||||
}
|
||||
|
||||
export function formatCarriedOverHover(count: number, sourceWeekIso?: string): string {
|
||||
const times = formatRescheduleCountLabel(count);
|
||||
const base = `Carried over ${times} — this WO was not completed and moved to the next period`;
|
||||
const sourceLabel = sourceWeekIso ? compactWeekLabel(sourceWeekIso) : "";
|
||||
if (!sourceLabel) {
|
||||
return base;
|
||||
}
|
||||
return `${base}. Source week: ${sourceLabel}.`;
|
||||
}
|
||||
|
||||
/** Non-terminal WOs scheduled before the viewed week are display carry-overs. */
|
||||
export function isCarryOverDisplayRow(row: CarryOverRow, weekMonday: string): boolean {
|
||||
if (isTerminalCarryOverStatus(row.status)) {
|
||||
return false;
|
||||
}
|
||||
const scheduled = toIsoDate(row.scheduledOn);
|
||||
return Boolean(scheduled) && scheduled < weekMonday;
|
||||
}
|
||||
|
||||
export function mapCarryOverDisplayDay(scheduledOn: string, weekMonday: string): string {
|
||||
const scheduled = toIsoDate(scheduledOn);
|
||||
if (!scheduled || !ISO_DATE.test(weekMonday)) {
|
||||
return "";
|
||||
}
|
||||
const sourceMonday = mondayOf(scheduled);
|
||||
const offsetMs =
|
||||
new Date(`${scheduled}T12:00:00`).getTime() - new Date(`${sourceMonday}T12:00:00`).getTime();
|
||||
const offset = Math.round(offsetMs / 86_400_000);
|
||||
if (offset < 0 || offset >= WEEKDAY_COUNT) {
|
||||
return weekMonday;
|
||||
}
|
||||
if (offset > FRIDAY_OFFSET) {
|
||||
return weekMonday;
|
||||
}
|
||||
return addDaysIso(weekMonday, offset);
|
||||
}
|
||||
|
||||
export function rowMatchesCarryOverDay(
|
||||
row: CarryOverRow,
|
||||
selectedDayIso: string,
|
||||
weekMonday: string,
|
||||
): boolean {
|
||||
if (!isCarryOverDisplayRow(row, weekMonday)) {
|
||||
return false;
|
||||
}
|
||||
return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso;
|
||||
}
|
||||
|
||||
/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */
|
||||
export function applyCarryOverDisplayDayFields<
|
||||
T extends CarryOverRow & { dayGroup: string; dayLabel: string },
|
||||
>(row: T, weekMonday: string): T {
|
||||
if (!isCarryOverDisplayRow(row, weekMonday)) {
|
||||
return row;
|
||||
}
|
||||
const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday);
|
||||
if (!displayDay) {
|
||||
return row;
|
||||
}
|
||||
return { ...row, ...deriveDayFields(displayDay) };
|
||||
}
|
||||
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media
|
||||
* tiles reads as an id. The original name (and its extension) survive after the prefix.
|
||||
*/
|
||||
const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i;
|
||||
|
||||
export function toDisplayFileName(fileName: string): string {
|
||||
const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim();
|
||||
return withoutPrefix || fileName;
|
||||
}
|
||||
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
|
||||
export type MediaSurfaceOwnership = {
|
||||
completionOwnedIds: ReadonlySet<string>;
|
||||
extraOwnedIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const EMPTY_OWNERSHIP: MediaSurfaceOwnership = {
|
||||
completionOwnedIds: new Set(),
|
||||
extraOwnedIds: new Set(),
|
||||
};
|
||||
|
||||
function isPhotoOrVideo(item: MediaFile): boolean {
|
||||
return item.fileType === "image" || item.fileType === "video";
|
||||
}
|
||||
|
||||
function isInFlight(item: MediaFile): boolean {
|
||||
return item.uploadStatus === "uploading" || item.uploadStatus === "failed";
|
||||
}
|
||||
|
||||
/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */
|
||||
export function isCompletionSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (isInFlight(item)) return true;
|
||||
if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return true;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return false;
|
||||
return item.purpose === "Extra" && isPhotoOrVideo(item);
|
||||
}
|
||||
|
||||
export function isExtrasSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (item.purpose === "Aveta") return true;
|
||||
if (item.purpose !== "Extra") return false;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return false;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return true;
|
||||
return !isPhotoOrVideo(item);
|
||||
}
|
||||
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
SEVERITY_LEVELS,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
|
||||
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
|
||||
return severity === 1 || severity === 2;
|
||||
}
|
||||
|
||||
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
|
||||
export function shouldAnnounceSeverityEscalation(
|
||||
previous: WorkOrderSeverity | null | undefined,
|
||||
next: WorkOrderSeverity | null | undefined,
|
||||
): next is 1 | 2 {
|
||||
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
|
||||
}
|
||||
|
||||
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
|
||||
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
|
||||
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
|
|
@ -96,7 +97,7 @@ export function filterByBarAndSearch(
|
|||
|
||||
if (row.scheduledOn) {
|
||||
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
|
||||
return false;
|
||||
return isCarryOverDisplayRow(row, bar.weekMonday);
|
||||
}
|
||||
} else if (row.targetWeek) {
|
||||
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
WorkOrderDayGroup,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { rowMatchesCarryOverDay } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -65,10 +66,17 @@ export function formatDayTabLabel(iso: string): string {
|
|||
}
|
||||
|
||||
export function rowMatchesSelectedDay(
|
||||
row: Pick<WorkOrderTableRow, "scheduledOn">,
|
||||
row: Pick<WorkOrderTableRow, "scheduledOn" | "status" | "carriedOver">,
|
||||
selectedDayIso: string,
|
||||
weekMonday?: string,
|
||||
): boolean {
|
||||
return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
|
||||
if (Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso) {
|
||||
return true;
|
||||
}
|
||||
if (!weekMonday) {
|
||||
return false;
|
||||
}
|
||||
return rowMatchesCarryOverDay(row, selectedDayIso, weekMonday);
|
||||
}
|
||||
|
||||
export function buildSelectedDayGroup(
|
||||
|
|
@ -83,6 +91,6 @@ export function buildSelectedDayGroup(
|
|||
key: `${month}/${day}`,
|
||||
label: formatDayTabLabel(date),
|
||||
date,
|
||||
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
|
||||
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date, weekMonday)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
PENDING_UPLIFT_GAP,
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
|
||||
|
||||
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
|
||||
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
];
|
||||
|
||||
export const SCHEDULED_NEEDS_DATE_MSG =
|
||||
"Set a Schedule On date before marking this work order as Scheduled.";
|
||||
|
||||
export const PAST_DUE_BADGE_TOOLTIP =
|
||||
"This WO missed its service window. Reschedule to unlock status updates.";
|
||||
|
||||
export const PAST_DUE_POPOVER_BODY =
|
||||
"This work order is past due. A new date must be scheduled before the status can be updated.";
|
||||
|
||||
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
|
||||
|
||||
export const COMPLETED_DOC_BLOCK_MSG =
|
||||
"Generate or mark the completion document as not required before completing this work order";
|
||||
|
||||
export const COMPLETED_MEDIA_BLOCK_MSG =
|
||||
"Upload at least one completion photo before completing this work order.";
|
||||
|
||||
const COMPLETE_ACTION = "completing this work order";
|
||||
|
||||
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
|
||||
|
||||
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
|
||||
|
||||
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
|
||||
["Dispatcher", "Assigned To"],
|
||||
["Company", "Vendor"],
|
||||
["Completed Date", "Completed Date"],
|
||||
["Service", "Service"],
|
||||
];
|
||||
|
||||
export type ScheduleDateInput = {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
};
|
||||
|
||||
export type StatusOptionState = {
|
||||
disabled: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function isLifecycleFieldEmpty(value?: string): boolean {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return EMPTY_FIELD_TOKENS.has(trimmed);
|
||||
}
|
||||
|
||||
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
|
||||
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
|
||||
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return !isLifecycleFieldEmpty(row.scheduledOn);
|
||||
}
|
||||
|
||||
export function isPreScheduleStatus(status: string): boolean {
|
||||
return PRE_SCHEDULE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
|
||||
if (gaps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
|
||||
([, label]) => label,
|
||||
);
|
||||
|
||||
if (fields.length === 1 && fields[0] === "Service") {
|
||||
return `Select a service before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes("WO number")) {
|
||||
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||
}
|
||||
if (gaps.includes("Completion document")) {
|
||||
return COMPLETED_DOC_BLOCK_MSG;
|
||||
}
|
||||
if (gaps.includes("At least 1 photo")) {
|
||||
return COMPLETED_MEDIA_BLOCK_MSG;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStatusOptionState(
|
||||
row: ScheduleDateInput,
|
||||
status: WizardWOStatus,
|
||||
closabilityGaps: string[],
|
||||
): StatusOptionState {
|
||||
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
||||
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
||||
}
|
||||
if (status === "Completed") {
|
||||
const title = formatAapCompletedBlockTitle(closabilityGaps);
|
||||
if (title) {
|
||||
return { disabled: true, title };
|
||||
}
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
|
||||
export function maybeAutoSchedulePatch(
|
||||
row: ScheduleDateInput & { status?: string },
|
||||
): WorkOrderTablePatch {
|
||||
const hasDate = hasConcreteScheduleDate(row);
|
||||
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
if (!hasDate && row.status === "Scheduled") {
|
||||
return { status: "Pending" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
|
||||
return (
|
||||
patch.status !== undefined ||
|
||||
patch.scheduledOn !== undefined ||
|
||||
patch.scheduleWeekOnly !== undefined ||
|
||||
patch.targetWeek !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAutoSchedulePatch(
|
||||
row: WorkOrderTableRow | undefined,
|
||||
patch: WorkOrderTablePatch,
|
||||
): WorkOrderTablePatch {
|
||||
if (!row || !patchTouchesScheduleOrStatus(patch)) {
|
||||
return patch;
|
||||
}
|
||||
const merged = {
|
||||
status: patch.status ?? row.status,
|
||||
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
|
||||
};
|
||||
return { ...patch, ...maybeAutoSchedulePatch(merged) };
|
||||
}
|
||||
|
||||
/** Apply schedule fields and the AAP auto-status for that next schedule. */
|
||||
export function withAutoScheduleStatus<T extends ScheduleDateInput & { status?: string }>(
|
||||
current: T,
|
||||
schedulePatch: Partial<T> & ScheduleDateInput,
|
||||
): Partial<T> {
|
||||
const next = {
|
||||
status: schedulePatch.status ?? current.status ?? "",
|
||||
scheduledOn:
|
||||
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
schedulePatch.scheduleWeekOnly !== undefined
|
||||
? schedulePatch.scheduleWeekOnly
|
||||
: current.scheduleWeekOnly,
|
||||
};
|
||||
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
|
||||
}
|
||||
|
||||
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
|
||||
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
|
||||
const autoStatus = maybeAutoSchedulePatch(row).status;
|
||||
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
|
||||
return autoStatus;
|
||||
}
|
||||
return (row.status || "Incomplete") as WizardWOStatus;
|
||||
}
|
||||
|
|
@ -80,3 +80,12 @@ export function applyVendorTableSave(
|
|||
onSuccess: isAssignedVendorId(fields.vendorId) ? notifyVendorAssigned : notifyWorkOrderSaved,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyAssignedToTableSave(
|
||||
onPatch: PersistPatchFn,
|
||||
id: string | number,
|
||||
dispatcherId: string,
|
||||
dispatcherName: string,
|
||||
) {
|
||||
onPatch(id, { dispatcherId, dispatcherName }, { onSuccess: notifyWorkOrderSaved });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
|
||||
const MEDIA_MIME_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
|
||||
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
|
||||
const extOk = extensions.has(fileExtension(file));
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
|
|
@ -13,24 +42,25 @@ export function isWorkOrderFileTooLarge(
|
|||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue