Merge branch 'main' into refactor/dispatch-detail-modal-split-v2

This commit is contained in:
Adam Moussa 2026-09-21 16:54:40 -04:00
commit cb6fd48916
No known key found for this signature in database
133 changed files with 6072 additions and 1320 deletions

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

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

21
.github/renovate.json vendored
View file

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

View file

@ -1,56 +0,0 @@
name: Terraform CI
# Static checks only. Plans run in HCP Terraform as speculative VCS runs on
# the PR (shoc-frontend-new-dev and shoc-frontend-new-staging). Applies are
# HCP auto-apply on merge to main (dev) and on a vX.Y.Z-staging tag (staging).
on:
pull_request:
branches: [main, dev]
paths:
- "terraform/**"
- "scripts/**"
- ".github/workflows/ci-terraform.yaml"
- ".github/workflows/deploy-web.yaml"
push:
branches: [main]
paths:
- "terraform/**"
- "scripts/**"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive terraform
- name: Validate live/dev
run: |
terraform -chdir=terraform/live/dev init -backend=false -input=false -lockfile=readonly -no-color
terraform -chdir=terraform/live/dev validate -no-color
- name: Validate live/staging
run: |
terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color
terraform -chdir=terraform/live/staging validate -no-color
- name: Import plan guard tests
run: python3 scripts/test-terraform-import-plan-check.py
- name: App/Terraform isolation tests
run: python3 scripts/test_check_app_terraform_isolation.py

View file

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

View file

@ -28,7 +28,6 @@ on:
- "docs/**" - "docs/**"
- "**/*.md" - "**/*.md"
- ".github/workflows/ci.yaml" - ".github/workflows/ci.yaml"
- ".github/workflows/ci-terraform.yaml"
- ".github/workflows/deploy-web.yaml" - ".github/workflows/deploy-web.yaml"
release: release:
types: [published] types: [published]

3
.gitignore vendored
View file

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

View file

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

View file

@ -14,24 +14,24 @@ isolation). A task is not done until this is green.
## Gate matrix ## Gate matrix
| Gate | Command / rule source | Enforced by | Scope | | Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | | Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | | Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | | Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | | Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | | Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | | Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | | Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | | 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) | | 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 | | Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | | Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | | Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | | HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | | CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | | GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` | | G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
## No-false-pass guarantees ## No-false-pass guarantees
@ -60,15 +60,18 @@ isolation). A task is not done until this is green.
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint + - **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
Prettier on staged files at commit; commitlint enforces Conventional Commits. Prettier on staged files at commit; commitlint enforces Conventional Commits.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org - **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs repository owns every job. `static` (`format:check` + `lint`), `build`
format/lint/build/tests, **and** a repo-owned `governance` job runs (`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability (Playwright visual), `browser-smoke` (`npm run test:e2e`), and `governance`
ratchets and repository gates are guaranteed from this repository regardless (`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete`
of the reusable workflow. fails unless all of those jobs succeeded and is the required merge-queue
- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):** check. Live G13 runs on
fmt, `init -backend=false`, validate, import-plan unit tests, and G13 `pull_request` (merge-base range), `merge_group` (each queued PR as a
classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`. first-parent commit), and locally. It skips `push`. A Terraform-only PR
stacked with an app-only PR still passes; a mixed change set still fails.
Terraform fmt/validate and the related unit tests run inside `governance` on
every event.
## Toolchain pin ## Toolchain pin

View file

@ -1,6 +1,6 @@
# SHOC Frontend (`shoc-frontend-new`) # SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) [![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml) [![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) ![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
@ -13,7 +13,8 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new` - **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today) - **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend` - **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture ## Architecture
@ -122,19 +123,23 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing ## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the - Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/` work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`). (e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow - Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint [Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time. rejects anything else at commit time.
- Open PRs against `main`. Protected branches need a green CI run and an - Open PRs against `main`. The PR body uses the three-section layout the
approving review from a code owner template pre-fills: Summary, Changes and value, Ticket. `main` needs the
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. `ci-complete` check and an approving review from a code owner
Merged branches are deleted automatically. (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow, approvals. PRs merge through the merge queue, so a branch does not need to
docs, and gate-script changes may travel with either side. `deploy-web.yaml` be updated with `main` before it merges. Merged branches are deleted
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket. automatically.
- A change set cannot mix `terraform/` with deployable application files (G13),
including each queued PR on the merge-group check. Workflow, docs, and
gate-script changes may travel with either side. `deploy-web.yaml` still
ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts - Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a `vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
prod distribution exists. prod distribution exists.
@ -144,19 +149,13 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
No stored AWS keys — OIDC only. Infrastructure and content deploy separately: No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`, calls and PRs to `main`, runs format, lint, build, sharded unit tests, visual
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): regression, Playwright smoke, and `npm run governance` as parallel jobs, then
format check, lint, build, tests; **and** runs a repo-owned `governance` job `ci-complete`.
that calls `npm run verify` so every gate (including the maintainability Conventions and gates are documented under
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)
and the Terraform gates) is guaranteed from this repository. Conventions
and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **Terraform CI**
([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml))
— fmt, `init -backend=false`, validate, and import-plan tests.
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml)) - **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys — push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`. `staging`. Syncs `dist/` to the bucket root and invalidates `/*`.

View file

@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`,
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`) `public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either fails G13. Workflow, docs, and gate-script changes may travel with either
side. side.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.
- **Changes and value** — grouped by area, each with the value it delivers.
- **Ticket** — the board key(s) when one applies; "None" otherwise.
- Link any ADR relied upon.
Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View file

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

View file

@ -50,6 +50,10 @@ function multipartBoundary(request: Request): string | undefined {
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
} }
function multipartFileName(body: string): string | undefined {
return /filename="([^"]+)"/.exec(body)?.[1];
}
async function hideQueryDevtools(page: Page) { async function hideQueryDevtools(page: Page) {
await page.addStyleTag({ await page.addStyleTag({
content: content:
@ -141,7 +145,7 @@ test.describe("Completion uploads on a mobile viewport", () => {
const request = route.request(); const request = route.request();
const body = request.postDataBuffer()?.toString("latin1") ?? ""; const body = request.postDataBuffer()?.toString("latin1") ?? "";
uploads.push({ contentType: request.headers()["content-type"] ?? "", body }); uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file"; const fileName = multipartFileName(body) ?? "file";
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
media.push(item); media.push(item);
return fulfillJson(route, item); return fulfillJson(route, item);
@ -156,18 +160,18 @@ test.describe("Completion uploads on a mobile viewport", () => {
const panel = await openCompletionTab(page, row.woNumber); const panel = await openCompletionTab(page, row.woNumber);
const chooser = page.waitForEvent("filechooser"); const chooser = page.waitForEvent("filechooser");
await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click(); await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click();
await ( const files = [
await chooser
).setFiles([
{ name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG }, { name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG },
{ name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 }, { name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 },
{ name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV }, { name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV },
]); ];
await (await chooser).setFiles(files);
await expect.poll(() => uploads.length).toBe(3); await expect.poll(() => uploads.length).toBe(files.length);
for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) { for (const { name } of files) {
expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/); const upload = uploads.find((item) => multipartFileName(item.body) === name);
expect(uploads[index]!.body).toContain(`filename="${name}"`); expect(upload, `multipart upload for ${name}`).toBeTruthy();
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
} }
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before"); await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");

View file

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

View file

@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await page.getByRole("button", { name: /Select technician/ }).click(); await page.getByRole("button", { name: /Select technician/ }).click();
await page await page
.getByRole("button", { name: /Adam Whyte/ }) .getByRole("button", { name: /Adam Whyte/ })

View file

@ -17,7 +17,7 @@
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py", "test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh", "test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh", "test:github-workflows": "bash scripts/check-github-workflows.sh",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py", "test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .", "format": "prettier --write .",

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, ".."); const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json"); const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
@ -218,15 +220,79 @@ function runRepositoryGate(label, script) {
return { label, status: result.status, error: result.error }; return { label, status: result.status, error: result.error };
} }
function runIsolationGate(baseRef) { function classifyIsolationPaths(files) {
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT, cwd: ROOT,
encoding: "utf8", encoding: "utf8",
input: `${files.join("\n")}\n`, input: files.length > 0 ? `${files.join("\n")}\n` : "",
}); });
} }
function firstParentCommitDiffs(baseRef) {
const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]);
return shas.map((sha) => ({
sha,
files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]),
}));
}
function runIsolationGate(baseRef, eventName) {
if (usesPerCommitIsolation(eventName)) {
const commits = firstParentCommitDiffs(baseRef);
return {
mode: "per-commit",
results: commits.map((commit) => ({
...classifyIsolationPaths(commit.files),
sha: commit.sha,
})),
};
}
// Diff from the merge base, not the moving base tip. A two-dot diff against
// a branch that has advanced reports everything the base gained after the
// branch point as if this change reverted it.
const mergeBase = gitText(["merge-base", baseRef, "HEAD"]);
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]);
return {
mode: "range",
mergeBase,
results: [{ ...classifyIsolationPaths(files), sha: null }],
};
}
function recordIsolationFailures(isolation, failures) {
const startError = isolation.results.find((result) => result.error);
if (startError) {
failures.push(`G13: could not start: ${startError.error.message}`);
}
if (isolation.results.some((result) => !result.error && result.status !== 0)) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
function logIsolationGate(baseRef, isolation) {
if (isolation.mode === "per-commit") {
console.log(
`G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`,
);
for (const result of isolation.results) {
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const prefix = result.sha ? result.sha.slice(0, 7) : "commit";
if (output) {
console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`);
}
}
return;
}
const mergeBase = isolation.mergeBase ?? "unresolvable";
console.log(
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
);
const result = isolation.results[0];
if (!result) return;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
}
function main() { function main() {
const failures = []; const failures = [];
const baseRef = resolveBaseRef(); const baseRef = resolveBaseRef();
@ -326,23 +392,30 @@ function main() {
} }
console.log("─".repeat(64)); console.log("─".repeat(64));
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`); const eventName = process.env.GITHUB_EVENT_NAME;
if (!baseRef) { if (!shouldRunLiveIsolation(eventName)) {
console.log(
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
);
} else if (!baseRef) {
console.log("G13: application and Terraform isolation (no base...HEAD)");
console.log(" FAIL (no valid base ref)"); console.log(" FAIL (no valid base ref)");
failures.push( failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.", "G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
); );
} else { } else {
const isolation = runIsolationGate(baseRef); let isolation;
if (isolation.error) { try {
console.log(" FAIL (could not start)"); isolation = runIsolationGate(baseRef, eventName);
failures.push(`G13: could not start: ${isolation.error.message}`); } catch (error) {
} else { console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim(); console.log(" FAIL (could not resolve isolation diffs)");
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); const message = error instanceof Error ? error.message : String(error);
if (isolation.status !== 0) { failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
failures.push("G13: do not mix deployable application files with terraform/"); }
} if (isolation) {
logIsolationGate(baseRef, isolation);
recordIsolationFailures(isolation, failures);
} }
} }

View file

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

View file

@ -30,7 +30,9 @@ export const API_PATHS = {
boardCreate: "workorders/board", boardCreate: "workorders/board",
boardSearch: "workorders/board/search", boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`, boardPatch: (id: string | number) => `workorders/${id}/board`,
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`, boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`, comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) => commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`, `workorders/${workOrderId}/comments/${commentId}`,
@ -167,6 +169,8 @@ export const API_PATHS = {
}, },
services: { services: {
list: "services", list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
}, },
user: { user: {
list: "User", list: "User",

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
return <ServicesRegistryView />;
}

View file

@ -0,0 +1,27 @@
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
export function formatUpliftDateTime(value: string): string {
if (!value) return "";
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasZone ? value : `${value}Z`);
if (Number.isNaN(date.getTime())) return "";
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
}
/**
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
* so only its date part is read and it never shifts with the viewer's time zone.
*/
export function formatUpliftCalendarDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (match == null) return "";
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
}

View file

@ -2,12 +2,27 @@ import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
function reportEvidenceError(error: unknown): void {
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
}
export function openUpliftEvidence(row: UpliftQueueItem): void { export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence") .downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch(reportEvidenceError);
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;
}
void upliftsApi
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => { .catch((error: unknown) => {
toast.error( tab?.close();
error instanceof Error ? error.message : "Unable to download evidence right now.", reportEvidenceError(error);
);
}); });
} }

View file

@ -1,4 +1,5 @@
import { useMemo, type ReactNode } from "react"; import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { import {
Box, Box,
Button, Button,
@ -7,21 +8,30 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Divider,
IconButton,
Tooltip, Tooltip,
} from "@mui/material"; } from "@mui/material";
import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts"; import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable"; const UNAVAILABLE_LABEL = "Unavailable";
const BORDERED_BLOCK_SX = {
border: 1,
borderColor: "divider",
borderRadius: 1,
px: 1.75,
py: 1.25,
} as const;
type ModalCallbacks = { type ModalCallbacks = {
onClose: () => void; onClose: () => void;
@ -31,24 +41,122 @@ type ModalCallbacks = {
onOpenAttachment: (row: UpliftQueueItem) => void; onOpenAttachment: (row: UpliftQueueItem) => void;
}; };
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
function DetailField({ label, children }: { label: string; children: ReactNode }) { function DetailField({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<Box> <Box>
<Text variant="label" tone="muted" className="block"> <Text variant="label" tone="muted" className="block">
{label} {label}
</Text> </Text>
{children} <Text variant="body">{children}</Text>
</Box> </Box>
); );
} }
function DetailSection({ title, children }: { title: string; children: ReactNode }) { function SubsectionTitle({ children }: { children: ReactNode }) {
return ( return (
<Box> <Text variant="label" as="h4" className="mb-1.5 mt-4 block">
<Text variant="heading" className="mb-1 block"> {children}
{title} </Text>
);
}
function vendorTechnicianLabel(item: UpliftQueueItem): string {
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
}
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
return (
<Box component="section" aria-label="Work order">
<Text variant="heading" className="mb-2 block">
Work order
</Text> </Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
<DetailField label="Assigned Dispatcher">
{item.workOrderDispatcherName || "Unassigned"}
</DetailField>
<DetailField label="Scheduled">
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
</DetailField>
</Box>
</Box>
);
}
function JustificationBlock({ notes }: { notes: string }) {
const text = notes.trim();
return (
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
{text.length > 0 && <Text variant="body">{text}</Text>}
{text.length === 0 && (
<Text variant="description" tone="muted" className="italic">
No justification provided.
</Text>
)}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
}}
>
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : fallback}
</Text>
</Box>
);
}
function ApprovedOnWoBreakdown({
breakdown,
unavailable,
}: {
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
label="Auto-approved (within allowance)"
value={breakdown.auto}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Admin-approved"
value={breakdown.admin}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box> </Box>
); );
} }
@ -67,142 +175,61 @@ function UpliftAttachments({
</Text> </Text>
); );
} }
const canOpen = item.evidenceDocumentId != null;
const extraCount = const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1 typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1 ? item.attachmentCount - 1
: 0; : 0;
return ( return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}> <Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Chip <Chip
size="small" variant="outlined"
label={item.evidenceFileName || "Attachment"} label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined} title={canOpen ? "Opens in a new tab" : undefined}
clickable={item.evidenceDocumentId != null} onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
/> />
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />} {extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
</Box> </Box>
); );
} }
function WorkOrderSection({ function UpliftRequestSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
item, item,
breakdown,
breakdownUnavailable,
onOpenAttachment, onOpenAttachment,
}: { }: {
item: UpliftQueueItem; item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void; onOpenAttachment: (row: UpliftQueueItem) => void;
}) { }) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return ( return (
<DetailSection title="Request"> <Box component="section" aria-label="Uplift request">
<DetailField label="Amount"> <Text variant="heading" className="mb-2 block">
<Text variant="body"> Uplift request
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
</Text>
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
</Text> </Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Requested By">
{item.requestedByVendorName || "Unknown requester"}
</DetailField>
<DetailField label="Requested At">
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
</DetailField>
</Box>
<SubsectionTitle>Justification</SubsectionTitle>
<JustificationBlock notes={item.vendorReason} />
<SubsectionTitle>Approved on WO</SubsectionTitle>
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
<SubsectionTitle>Attachments</SubsectionTitle>
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</Box> </Box>
); );
} }
@ -223,6 +250,19 @@ function PendingModalActions({
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`; const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return ( return (
<> <>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}> <Tooltip title={tooltip}>
<span> <span>
<Button <Button
@ -236,73 +276,61 @@ function PendingModalActions({
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</> </>
); );
} }
function UpliftModalActions({ function RevokeModalAction({
item, item,
closed,
canRevoke, canRevoke,
approvePending,
revokePending, revokePending,
onApprove,
onReject,
onRevoke, onRevoke,
}: { }: {
item: UpliftQueueItem; item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean; canRevoke: boolean;
approvePending: boolean;
revokePending: boolean; revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
}) { }) {
const closed = item.workOrderClosed === true;
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : ""; const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return ( return (
<> <Tooltip title={revokeTooltip}>
{item.status === "Pending" && ( <span>
<PendingModalActions <Button
item={item} size="small"
approvePending={approvePending} variant="outlined"
onApprove={onApprove} color="error"
onReject={onReject} disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
/> onClick={() => onRevoke(item)}
)} >
{item.status === "Approved" && ( Revoke
<Tooltip title={revokeTooltip}> </Button>
<span> </span>
<Button </Tooltip>
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
)}
</>
); );
} }
export function UpliftDetailModal({ function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});
const breakdown = useMemo<ExposureBreakdown>(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
return { breakdown, unavailable: woUpliftsQuery.isError };
}
function UpliftDetailContent({
item, item,
canRevoke, canRevoke,
approvePending, approvePending,
@ -312,98 +340,102 @@ export function UpliftDetailModal({
onReject, onReject,
onRevoke, onRevoke,
onOpenAttachment, onOpenAttachment,
}: {
item: UpliftQueueItem;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const { breakdown, unavailable } = useExposureBreakdown(item);
const isPending = item.status === "Pending";
const isApproved = item.status === "Approved";
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
return (
<>
<DialogTitle sx={{ pr: 6 }}>
<Text variant="title" as="div">
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
</Text>
<Text variant="description" tone="muted" when={subtitle.length > 0}>
{subtitle}
</Text>
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{ position: "absolute", top: 12, right: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
bgcolor: "action.hover",
borderRadius: 1,
px: 2,
py: 1.5,
mb: 2,
}}
>
<Text variant="label" tone="muted">
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />
<Divider sx={{ my: 2.5 }} />
<UpliftRequestSection
item={item}
breakdown={breakdown}
breakdownUnavailable={unavailable}
onOpenAttachment={onOpenAttachment}
/>
</DialogContent>
{(isPending || isApproved) && (
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
{isPending && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{isApproved && (
<RevokeModalAction
item={item}
canRevoke={canRevoke}
revokePending={revokePending}
onRevoke={onRevoke}
/>
)}
</DialogActions>
)}
</>
);
}
export function UpliftDetailModal({
item,
...rest
}: { }: {
item: UpliftQueueItem | null; item: UpliftQueueItem | null;
canRevoke: boolean; canRevoke: boolean;
approvePending: boolean; approvePending: boolean;
revokePending: boolean; revokePending: boolean;
} & ModalCallbacks) { } & ModalCallbacks) {
const open = item != null; if (item == null) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
return null; return null;
} }
return ( return (
<Dialog <Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
open={open} <UpliftDetailContent item={item} {...rest} />
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
</Dialog> </Dialog>
); );
} }

View file

@ -6,7 +6,10 @@ import {
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence"; import {
openUpliftEvidence,
openUpliftEvidenceInNewTab,
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils"; import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
onApprove={handleApprove} onApprove={handleApprove}
onReject={handleRejectRequest} onReject={handleRejectRequest}
onRevoke={handleRevokeRequest} onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence} onOpenAttachment={openUpliftEvidenceInNewTab}
/> />
)} )}
<UpliftDecisionDialogs controller={controller} /> <UpliftDecisionDialogs controller={controller} />

View file

@ -97,12 +97,13 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion; const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status); const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// Uploading a signed PDF finalizes through the same Generate flow, so the // The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// completedDate/address prerequisites still persist before docStatus Yes. // one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({ const upload = useCompDocPdfUpload({
workOrderId: row.id, workOrderId: row.id,
rowVersion, rowVersion,
onUploaded: dialog.handleGenerate, onUploaded: dialog.markSignedPdfUploaded,
}); });
// A signed PDF on file means the document exists even if docState has not caught up. // A signed PDF on file means the document exists even if docState has not caught up.

View file

@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { import type {
WorkOrderTablePatch, WorkOrderTablePatch,
@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean {
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status); return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
} }
type StateSetter<T> = Dispatch<SetStateAction<T>>;
type UseSlideOverEditStateArgs = { type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null; row: WorkOrderTableRow | null;
editMode: boolean; editMode: boolean;
@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = {
onClose: () => void; onClose: () => void;
closeDisabled: boolean; closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void; setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
openRequestKey?: number;
}; };
export function useSlideOverEditState({ function useSlideOverInitialization({
row, row,
editMode, editMode,
infoSource, initialTab,
onSave,
onClose,
closeDisabled,
setTab, setTab,
}: UseSlideOverEditStateArgs) { setDraft,
const [editing, setEditing] = useState(false); setBaseline,
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null); setEditing,
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ setShowUnsaved,
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null); setUnsavedIntent,
const [showUnsaved, setShowUnsaved] = useState(false); }: {
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null); row: WorkOrderTableRow | null;
editMode: boolean;
// Reset draft/baseline only when the opened WO identity or editMode intent changes — initialTab: SlideOverTab;
// not on every optimistic parent-row field update during a failed/in-flight save. setTab: (tab: SlideOverTab) => void;
setDraft: StateSetter<WorkOrderTableRow | null>;
setBaseline: StateSetter<WorkOrderTableRow | null>;
setEditing: StateSetter<boolean>;
setShowUnsaved: StateSetter<boolean>;
setUnsavedIntent: StateSetter<"close" | "cancel-edit" | null>;
}) {
useEffect(() => { useEffect(() => {
if (!row) { if (!row) {
setDraft(null); setDraft(null);
@ -59,12 +69,50 @@ export function useSlideOverEditState({
setDraft(row); setDraft(row);
setBaseline(row); setBaseline(row);
setEditing(editMode && !isSlideOverInfoLocked(row.status)); setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info"); setTab(editMode ? "info" : initialTab);
setShowUnsaved(false); setShowUnsaved(false);
setUnsavedIntent(null); setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches. // Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]); }, [row?.id, editMode]);
}
export function useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
initialTab = "info",
openRequestKey = 0,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useSlideOverInitialization({
row,
editMode,
initialTab,
setTab,
setDraft,
setBaseline,
setEditing,
setShowUnsaved,
setUnsavedIntent,
});
useEffect(() => {
if (row?.id == null || editMode) return;
setTab(initialTab);
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
const lockStatus = infoSource?.status ?? row?.status; const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus); const isInfoLocked = isSlideOverInfoLocked(lockStatus);
@ -95,8 +143,7 @@ export function useSlideOverEditState({
}); });
}, [infoSource, editing, baseline]); }, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource; const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false; if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline); return isSlideOverDraftDirty(activeDraft, dirtyBaseline);

View file

@ -20,7 +20,6 @@ import {
isWorkOrderFullyLocked, isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock"; } from "@/domain/work-orders/utils/work-order-lock";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state"; import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state"; import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -35,24 +34,21 @@ type UseWorkOrderSlideOverArgs = {
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void; onClose: () => void;
saving?: boolean; saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
}; };
type CompletionUploadMutate = ( type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
function uploadSlideOverCompletionPdf({ function uploadSlideOverCompletionPdf({
file, file,
row, row,
infoSource, infoSource,
onSave,
mutate, mutate,
}: { }: {
file: File; file: File;
row: WorkOrderTableRow | null; row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined; infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate; mutate: CompletionUploadMutate;
}) { }) {
const version = infoSource?.rowVersion; const version = infoSource?.rowVersion;
@ -60,27 +56,11 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again."); toast.error("Missing work order version. Refresh and try again.");
return; return;
} }
mutate( // The upload transitions the completion document to Yes and bumps the work-order row
{ file, workOrderVersion: version }, // version in the same server transaction; the mutation's onSuccess refreshes the board.
{ // A client docStatus patch here would carry the pre-upload version and 409 after a
onSuccess: async () => { // successful upload, so it is intentionally omitted.
const patch: WorkOrderTablePatch = { docStatus: "Yes" }; mutate({ file, workOrderVersion: version });
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
} }
function resolveSlideOverSource( function resolveSlideOverSource(
@ -112,6 +92,8 @@ export function useWorkOrderSlideOver({
onSave, onSave,
onClose, onClose,
saving, saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) { }: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext(); const { user } = useAuthContext();
const workOrderId = row?.id; const workOrderId = row?.id;
@ -160,6 +142,8 @@ export function useWorkOrderSlideOver({
onClose, onClose,
closeDisabled, closeDisabled,
setTab, setTab,
initialTab,
openRequestKey,
}); });
const uploadCompletionPdf = (file: File) => { const uploadCompletionPdf = (file: File) => {
@ -167,7 +151,6 @@ export function useWorkOrderSlideOver({
file, file,
row, row,
infoSource, infoSource,
onSave,
mutate: uploadCompletionMutation.mutate, mutate: uploadCompletionMutation.mutate,
}); });
}; };

View file

@ -11,7 +11,7 @@ import type {
WorkOrderTablePatch, WorkOrderTablePatch,
WorkOrderTableRow, WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over"; import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav"; import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -35,6 +35,8 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void; onClose: () => void;
saving?: boolean; saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
}; };
export function WorkOrderSlideOver({ export function WorkOrderSlideOver({
@ -52,8 +54,18 @@ export function WorkOrderSlideOver({
onDuplicateFound, onDuplicateFound,
onClose, onClose,
saving, saving,
initialTab,
openRequestKey,
}: WorkOrderSlideOverProps) { }: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving }); const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openRequestKey,
});
const { infoSource, activeDraft } = state; const { infoSource, activeDraft } = state;
return ( return (

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { import {
buildCompanyOptions, buildCompanyOptions,
buildTechnicianOptions, buildTechnicianOptions,
@ -37,7 +37,7 @@ export function useVendorDialogForm({
vendors, vendors,
onSave, onSave,
}: UseVendorDialogFormArgs) { }: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor(); const addTechnicians = useAddTechniciansToCompany();
const [selectedCompany, setSelectedCompany] = useState(company); const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() => const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors), resolveTechnicianKey(vendorId, tech, company, vendors),
@ -119,7 +119,7 @@ export function useVendorDialogForm({
anchorCompanyName, anchorCompanyName,
tech: selected?.name ?? t, tech: selected?.name ?? t,
phone, phone,
createVendor, addTechnicians,
onSave, onSave,
onOpenChange, onOpenChange,
setSaveError, setSaveError,
@ -141,7 +141,7 @@ export function useVendorDialogForm({
companyVendor, companyVendor,
saveError, saveError,
canSubmit, canSubmit,
isPending: createVendor.isPending, isPending: addTechnicians.isPending,
handleSave, handleSave,
handleCompanyPick: (nextCompany: string) => { handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey); const selectedOption = technicianOptions.find((option) => option.key === selectedKey);

View file

@ -1,9 +1,8 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor"; import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
type UseVendorDialogSaveArgs = { type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[]; allVendors: VendorDropdownItem[];
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
anchorCompanyName: string; anchorCompanyName: string;
tech: string; tech: string;
phone: string; phone: string;
createVendor: ReturnType<typeof useCreateVendor>; addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
onSave: (patch: VendorPatch) => void; onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void; setSaveError: (error: string | null) => void;
canSubmit: boolean; canSubmit: boolean;
}; };
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
}: {
allVendors: VendorDropdownItem[];
pendingPayload: VendorFormValues;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
}): Promise<VendorDropdownItem> {
const anchor = allVendors.find(
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
);
if (!anchor) {
throw new Error("Select an existing vendor company before adding a technician.");
}
const [created] = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: [
{
contactName: pendingPayload.contactName,
phone: phone.trim() || (pendingPayload.phone ?? ""),
tradeSpecialties: pendingPayload.tradeSpecialties,
},
],
});
if (created == null) throw new Error("Failed to create technician");
return created;
}
export function useVendorDialogSave({ export function useVendorDialogSave({
allVendors, allVendors,
pendingCreates, pendingCreates,
@ -30,7 +61,7 @@ export function useVendorDialogSave({
anchorCompanyName, anchorCompanyName,
tech, tech,
phone, phone,
createVendor, addTechnicians,
onSave, onSave,
onOpenChange, onOpenChange,
setSaveError, setSaveError,
@ -53,11 +84,12 @@ export function useVendorDialogSave({
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) { if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try { try {
const created = await createVendor.mutateAsync({ saveVendor = await persistDraftTechnician({
...pendingPayload, allVendors,
phone: phone.trim() || pendingPayload.phone, pendingPayload,
phone,
addTechnicians,
}); });
saveVendor = vendorToDropdownItem(created);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician"; const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message); setSaveError(message);

View file

@ -51,6 +51,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
<WorkOrderSlideOver <WorkOrderSlideOver
row={page.activeSlideOverRow} row={page.activeSlideOverRow}
editMode={page.slideOverEdit} editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
openRequestKey={page.slideOverOpenRequestKey}
users={users} users={users}
sites={locations} sites={locations}
vendors={vendors} vendors={vendors}

View file

@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes"; import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type"; import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
@ -63,7 +64,7 @@ export function useNewWoWizardController({
} }
}; };
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so // Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock // holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs // pointer events, and a second click would create a second work order. The save runs
@ -72,6 +73,11 @@ export function useNewWoWizardController({
onOpenChange(false); onOpenChange(false);
void saveCompanyNotes(createdDraft); void saveCompanyNotes(createdDraft);
}); });
const createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,
createMutation: createWorkOrder,
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({ useWizardDuplicateActions({
open, open,

View file

@ -0,0 +1,93 @@
import { toast } from "react-toastify";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type {
WizardNewTechnician,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type UseWizardCreateWithTechniciansArgs = {
vendors: VendorDropdownItem[];
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
}
/** Draft changes applied once inline technicians exist as real vendor records. */
export function buildCreatedTechniciansPatch(
draft: WorkOrderWizardDraft,
company: string,
pending: WizardNewTechnician[],
created: VendorDropdownItem[],
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;
}
/**
* Persists technicians registered inline on the Vendor & time step under the
* selected company, then creates the work order. Nothing is written before Create, so
* cancelling the wizard leaves no stray vendor records.
*/
export function useWizardCreateWithTechnicians({
vendors,
patchDraft,
createMutation,
}: UseWizardCreateWithTechniciansArgs) {
const addTechnicians = useAddTechniciansToCompany();
const mutate = (draft: WorkOrderWizardDraft) => {
const pending = pendingTechnicians(draft);
const company = (draft.vendorCompany ?? "").trim();
if (pending.length === 0 || company === "") {
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
}
const anchor = vendors.find((vendor) => vendor.companyName === company);
if (!anchor) {
toast.error("Select an existing vendor company before adding technicians.");
return;
}
void (async () => {
try {
const created = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: pending.map((technician) => ({
contactName: technician.name,
phone: technician.phone,
tradeSpecialties: anchor.tradeSpecialties,
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
}
})();
};
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
}

View file

@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
onPatch, onPatch,
}: WizardStepLocationServiceSelectProps) { }: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> => const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {}; nextPm !== draft.pm
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
const selectSvc = (p: string) => { const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p); const selected = services.find((service) => service.value === p);

View file

@ -1,4 +1,5 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { import {
@ -8,10 +9,7 @@ import {
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils"; import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import { import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { import {
getClosabilityGaps, getClosabilityGaps,
wizardDraftToClosabilityInput, wizardDraftToClosabilityInput,
@ -30,7 +28,6 @@ import {
ColorDot, ColorDot,
Detail, Detail,
WizLabel, WizLabel,
WizLabelRow,
WizSubLabel, WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <WizardVendorCompanyFields
<WizLabelRow draft={draft}
optional vendors={vendors}
onClear={ selectedVendor={selectedVendor}
draft.vendorId !== "" onPatch={onPatch}
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" }) />
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{draft.vendorId !== "" && selectedVendor != null && ( {draft.vendorId !== "" && selectedVendor != null && (
<WizardVendorDetail <WizardVendorDetail

View file

@ -0,0 +1,141 @@
import { Building2, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
selectedVendor: VendorDropdownItem | undefined;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
const ICON_STYLE = { color: "var(--muted-foreground)" };
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
</span>
);
}
/** Company-first vendor selection with inline technician registration (prototype parity). */
export function WizardVendorCompanyFields({
draft,
vendors,
selectedVendor,
onPatch,
}: WizardVendorCompanyFieldsProps) {
const company = draft.vendorCompany || selectedVendor?.companyName || "";
const companyOptions = buildCompanyOptions(vendors, company);
const technicianOptions = vendors
.filter((vendor) => vendor.companyName === company)
.map((vendor) => ({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
return (
<>
<div>
<WizLabelRow
optional
onClear={
company === ""
? undefined
: () =>
onPatch({
vendorCompany: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
>
Company
</WizLabelRow>
<WizardFieldSelect
value={company}
placeholder="Select company…"
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
selectedVendor ? (
<TechnicianAvatar vendor={selectedVendor} />
) : (
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{company !== "" && (
<WizardVendorNewTechnicians
technicians={draft.newTechnicians ?? []}
onChange={(newTechnicians) => onPatch({ newTechnicians })}
/>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { X } from "lucide-react";
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardVendorNewTechniciansProps = {
technicians: WizardNewTechnician[];
onChange: (technicians: WizardNewTechnician[]) => void;
};
/**
* Inline technician registration on the wizard's Vendor & time step.
* Rows stay local to the draft; they are persisted under the selected company only
* when the work order is created, so cancelling the wizard never creates a vendor.
*/
export function WizardVendorNewTechnicians({
technicians,
onChange,
}: WizardVendorNewTechniciansProps) {
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<>
{technicians.map((technician, index) => {
const label = `New Technician ${index + 1}`;
return (
<div
key={index}
role="group"
aria-label={label}
style={{
padding: 10,
borderRadius: "var(--radius-md)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: "var(--color-text-muted)" }}
>
{label}
</span>
<button
type="button"
title="Remove technician"
aria-label={`Remove ${label}`}
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
style={{ color: "var(--color-text-muted)" }}
>
<X size={13} aria-hidden />
</button>
</div>
<div className="flex flex-col gap-2">
<div>
<WizSubLabel>Technician name</WizSubLabel>
<input
aria-label={`${label} name`}
value={technician.name}
onChange={(e) => update(index, { name: e.target.value })}
placeholder="Technician name"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
type="tel"
aria-label={`${label} phone`}
value={technician.phone}
onChange={(e) => update(index, { phone: e.target.value })}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
className="text-left text-[12px] font-medium"
style={{ color: "var(--color-primary)" }}
>
+ Add technician
</button>
</>
);
}

View file

@ -162,11 +162,22 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
} }
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {
setGeneratedAt(fmtDocNow());
setDocState("generated");
refreshAuthoritativeState();
}
return { return {
handleMarkNotRequired, handleMarkNotRequired,
handleUndo, handleUndo,
handleSaveDraft, handleSaveDraft,
handleGenerate, handleGenerate,
handleDownloadPdf, handleDownloadPdf,
markSignedPdfUploaded,
}; };
} }

View file

@ -18,17 +18,19 @@ export function useDashboardDrilldownFilters(
}, [onApply]); }, [onApply]);
useEffect(() => { useEffect(() => {
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
// The applied params were cleared; a later drilldown link on this mounted page applies too.
appliedRef.current = false;
return;
}
if (appliedRef.current) { if (appliedRef.current) {
return; return;
} }
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
return;
}
appliedRef.current = true; appliedRef.current = true;
setFromDashboard(true); setFromDashboard(searchParams.get("fromDashboard") === "1");
onApplyRef.current(drilldown); onApplyRef.current(drilldown);
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);

View file

@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
parseWorkOrderDeepLink,
withoutWorkOrderDeepLink,
} from "@/domain/work-orders/utils/work-order-deep-link";
type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void;
/**
* Opens `?wo=<id>&tab=<tab>` in the slide-over once that work order's detail loads, whatever week
* the board is showing, then removes the params so closing the slide-over does not reopen it.
*/
export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
const [searchParams, setSearchParams] = useSearchParams();
const link = parseWorkOrderDeepLink(searchParams);
const workOrderId = link?.workOrderId;
const tab = link?.tab ?? "info";
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
const onOpenRef = useRef(onOpen);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
if (workOrderId == null) {
return;
}
const loaded = data != null && String(data.info.id) === workOrderId;
if (!loaded && error == null) {
return;
}
if (loaded) {
onOpenRef.current(data.info, false, tab);
}
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
}

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { import {
useWorkOrderLocations, useWorkOrderLocations,
@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data"; import { useWorkOrdersTableData } from "./use-work-orders-table-data";
export function useWorkOrdersListPage() { function useSlideOverOpenState() {
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null); const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false); const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
const handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
setSlideOverOpenRequestKey((key) => key + 1);
};
return {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
};
}
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
} = useSlideOverOpenState();
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null); const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null); const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null); const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -74,11 +110,6 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow; return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]); }, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => { const handleCloseSlideOver = () => {
setSlideOverRow(null); setSlideOverRow(null);
setSlideOverEdit(false); setSlideOverEdit(false);
@ -130,6 +161,8 @@ export function useWorkOrdersListPage() {
wizardOpen, wizardOpen,
setWizardOpen, setWizardOpen,
slideOverEdit, slideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
confirmCancel, confirmCancel,
setConfirmCancel, setConfirmCancel,
confirmComplete, confirmComplete,

View file

@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
const page = useWorkOrdersListPage(); const page = useWorkOrdersListPage();
const { filters, tableData } = page; const { filters, tableData } = page;
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters); const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink(page.handleOpenSlideOver);
return ( return (
<Box className="flex h-full flex-col overflow-hidden p-2.5"> <Box className="flex h-full flex-col overflow-hidden p-2.5">

View file

@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
const SETTINGS_LINKS = [ const SETTINGS_LINKS = [
{ path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" }, { path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" }, { path: "/settings/task-templates", label: "Task Templates" },
] as const; ] as const;

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type UserMenuHeaderProps = {
initials: string;
fullname: string;
email: string;
};
function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) {
return (
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{initials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{fullname}
</Typography>
<Typography variant="caption" color="text.secondary">
{email}
</Typography>
</Box>
</Box>
);
}
export function AppTopbarUserMenu() {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<>
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<UserMenuHeader
initials={userInitials}
fullname={user?.fullname || "User"}
email={user?.email || ""}
/>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View file

@ -1,49 +1,14 @@
import { useState } from "react"; import { AppBar, Box, Toolbar } from "@mui/material";
import { useNavigate } from "react-router"; import { Menu as MenuIcon } from "lucide-react";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
AppBar,
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context"; import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
import { getPrimaryUserRole } from "@/lib/auth/user-utils"; import { NotificationBell } from "@/components/notifications/notification-bell";
type AppTopbarProps = { type AppTopbarProps = {
onToggleNav: () => void; onToggleNav: () => void;
}; };
export function AppTopbar({ onToggleNav }: AppTopbarProps) { export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return ( return (
<AppBar <AppBar
position="sticky" position="sticky"
@ -97,135 +62,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
<Box <NotificationBell />
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
</Toolbar>
<Menu <AppTopbarUserMenu />
anchorEl={userAnchor} </Toolbar>
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</AppBar> </AppBar>
); );
} }

View file

@ -0,0 +1,98 @@
import { useState } from "react";
import { Box, Drawer } from "@mui/material";
import { Bell, X } from "lucide-react";
import { Text } from "@/components/ui/text";
import { NotificationFeedActions } from "./notification-feed-actions";
import { NotificationFeedList } from "./notification-feed-list";
import { useNotificationCenter } from "./use-notification-center";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const center = useNotificationCenter(() => setOpen(false));
const unreadCount = center.unreadIds.size;
return (
<>
<Box
component="button"
type="button"
onClick={() => setOpen(true)}
title="Notifications"
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 38,
height: 38,
borderRadius: "6px",
border: "none",
cursor: "pointer",
overflow: "visible",
backgroundColor: "var(--color-header-surface)",
color: "var(--color-header-icon)",
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<Bell size={18} aria-hidden />
{unreadCount > 0 && (
<span
aria-hidden
className="flex items-center justify-center"
style={{
position: "absolute",
top: 2,
right: 2,
minWidth: 16,
height: 16,
padding: "0 4px",
borderRadius: 9999,
background: "var(--destructive)",
color: "var(--destructive-foreground)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1,
}}
>
{unreadCount}
</span>
)}
</Box>
<Drawer
anchor="right"
open={open}
onClose={() => setOpen(false)}
slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }}
>
<div
className="flex shrink-0 items-center gap-2"
style={{ padding: "16px 20px", borderBottom: "1px solid var(--color-border)" }}
>
<Text
as="h2"
variant="title"
sx={{ flex: 1, fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)" }}
>
Notifications
</Text>
<NotificationFeedActions center={center} />
<button
type="button"
onClick={() => setOpen(false)}
title="Close"
aria-label="Close notifications"
className="shrink-0 cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<X size={16} aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 20px 20px" }}>
<NotificationFeedList center={center} />
</div>
</Drawer>
</>
);
}

View file

@ -0,0 +1,33 @@
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
center: NotificationCenter;
};
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
return (
<>
<button
type="button"
onClick={center.markAllRead}
disabled={!hasUnread}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)" }}
>
Mark all read
</button>
<button
type="button"
onClick={center.dismissAll}
disabled={!hasItems}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-text-muted)" }}
>
Clear all
</button>
</>
);
}

View file

@ -0,0 +1,89 @@
import { CircularProgress } from "@mui/material";
import { Inbox } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { NotificationCenter } from "./use-notification-center";
import { NotificationRow } from "./notification-row";
type NotificationFeedListProps = {
center: NotificationCenter;
};
export function NotificationFeedList({ center }: NotificationFeedListProps) {
if (center.isLoading) {
return (
<div className="flex justify-center py-16">
<CircularProgress size={24} aria-label="Loading notifications" />
</div>
);
}
if (center.error != null && !center.hasData) {
return (
<Text variant="error" className="py-6">
Notifications could not be loaded. They will retry automatically.
</Text>
);
}
if (center.sections.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16">
<Inbox size={24} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="description" sx={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Nothing needs your attention
</Text>
</div>
);
}
return (
<div>
{center.sections.map((section) => (
<section
key={section.reason}
aria-label={`${section.label}, ${section.count}`}
style={{ marginTop: 16 }}
>
<div className="flex items-center gap-2" style={{ marginBottom: 4 }}>
<Text
as="h3"
variant="label"
sx={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--color-text-muted)",
}}
>
{section.label}
</Text>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--color-text-secondary)",
background: "var(--color-bg-muted)",
borderRadius: "var(--radius-sm)",
padding: "1px 6px",
}}
>
{section.count}
</span>
</div>
<ul className="m-0 list-none p-0">
{section.items.map((item) => (
<NotificationRow
key={item.id}
item={item}
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
/>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,127 @@
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Bell,
Clock,
Inbox,
MessageSquare,
ShieldAlert,
Wrench,
X,
} from "lucide-react";
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
"severity-critical": AlertTriangle,
sla: AlertTriangle,
mentions: MessageSquare,
unassigned: Inbox,
"due-soon": Clock,
"no-vendor": Wrench,
"aveta-missing": ShieldAlert,
activity: MessageSquare,
"vendor-conflict": Wrench,
};
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
};
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
return null;
}
return (
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
{item.workOrders.map((workOrder) => {
const url = workOrderViewUrl(workOrder.id);
return (
<Link
key={workOrder.id}
to={url}
onClick={(event) => {
event.preventDefault();
onOpen(item, url);
}}
className="text-xs underline-offset-2 hover:underline"
style={{ color: "var(--color-primary)" }}
>
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
</Link>
);
})}
</span>
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
padding: "10px 8px",
marginInline: -8,
borderRadius: "var(--radius-md)",
borderBottom: "1px solid var(--color-bg-muted)",
}}
data-unread={unread}
>
<span className="flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<WorkOrderLinks item={item} onOpen={onOpen} />
</span>
{isDismissable(item) && (
<button
type="button"
onClick={() => onDismiss(item)}
title="Dismiss"
aria-label="Dismiss notification"
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
>
<X size={13} aria-hidden />
</button>
)}
</li>
);
}

View file

@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useNavigate } from "react-router";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
isDismissable,
visibleNotificationSections,
} from "@/domain/notifications/utils/visible-notification-sections";
export function useNotificationCenter(onNavigate?: () => void) {
const navigate = useNavigate();
const feed = useNotificationFeed();
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
const sections = useMemo(
() => visibleNotificationSections(feed.data?.sections ?? [], dismissedIds),
[feed.data, dismissedIds],
);
const items = useMemo(() => sections.flatMap((section) => section.items), [sections]);
const unreadIds = useMemo(
() => new Set(items.filter((item) => !readIds.has(item.id)).map((item) => item.id)),
[items, readIds],
);
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
markRead([item.id]);
if (url == null) return;
onNavigate?.();
navigate(url);
};
return {
sections,
unreadIds,
isLoading: feed.isLoading,
hasData: feed.data != null,
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};
}
export type NotificationCenter = ReturnType<typeof useNotificationCenter>;

View file

@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
(status) => status !== "Completed", (status) => status !== "Completed",
); );
export function workOrderDrilldownUrl(search: URLSearchParams): string { export function workOrderDrilldownUrl(
const query = search.toString(); search: URLSearchParams,
source: "dashboard" | "notification" = "dashboard",
): string {
const params = new URLSearchParams(search);
if (source === "dashboard") {
params.set("fromDashboard", "1");
}
const query = params.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE; return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
} }

View file

@ -0,0 +1,10 @@
import { apiGet } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
} from "@/domain/notifications/types/notification-feed";
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
};

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
export type NotificationSession = {
dismissedIds: ReadonlySet<string>;
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
export function useNotificationSession(): NotificationSession {
const session = useContext(NotificationSessionContext);
if (!session) {
throw new Error("useNotificationSession must be used within a NotificationSessionProvider");
}
return session;
}

View file

@ -0,0 +1,38 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
if (ids.every((id) => current.has(id))) {
return current;
}
const next = new Set(current);
ids.forEach((id) => next.add(id));
return next;
}
/**
* Dismissed and read notification ids for this app session only. Nothing is persisted, so a
* reload starts over and a condition that still holds shows up again.
*/
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
}, []);
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
);
return (
<NotificationSessionContext.Provider value={value}>
{children}
</NotificationSessionContext.Provider>
);
}

View file

@ -0,0 +1,62 @@
import { z } from "zod";
/** Fixed reason order; it breaks ties between sections of equal severity. */
export const NOTIFICATION_REASON_ORDER = [
"severity-critical",
"sla",
"mentions",
"unassigned",
"due-soon",
"no-vendor",
"aveta-missing",
"activity",
"vendor-conflict",
] as const;
export const NOTIFICATION_SEVERITY_RANK = {
Critical: 3,
High: 2,
Medium: 1,
Low: 0,
} as const;
const severitySchema = z.enum(["Critical", "High", "Medium", "Low"]);
const targetSchema = z.object({
kind: z.enum(["workOrder", "queue"]),
workOrderId: z.number().nullish(),
tab: z.string().nullish(),
queue: z.string().nullish(),
});
const itemSchema = z.object({
id: z.string(),
reason: z.string(),
severity: severitySchema,
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
rowType: z.enum(["dismissable", "acknowledge"]),
title: z.string(),
count: z.number(),
triggeredAt: z.string(),
target: targetSchema,
workOrders: z.array(z.object({ id: z.number(), number: z.string().nullish() })),
});
const sectionSchema = z.object({
reason: z.string(),
label: z.string(),
severity: severitySchema,
count: z.number(),
items: z.array(itemSchema),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
export type NotificationTarget = z.infer<typeof targetSchema>;
export type NotificationItem = z.infer<typeof itemSchema>;
export type NotificationSection = z.infer<typeof sectionSchema>;
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;

View file

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import { queryKeys } from "@/infra/query-key/query-key";
/** Live conditions are recomputed server-side on every read, so polling keeps counts current. */
export const NOTIFICATION_FEED_REFRESH_MS = 30_000;
export function useNotificationFeed() {
return useQuery({
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
meta: { suppressErrorToast: true },
});
}

View file

@ -0,0 +1,46 @@
import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
export function unassignedQueueUrl(): string {
const search = new URLSearchParams();
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search, "notification");
}
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
export function chooseVendorUrl(workOrderId: number): string {
return `/workorders/edit/${workOrderId}?assignVendor=1`;
}
export function workOrderViewUrl(workOrderId: number): string {
return `/workorders/${workOrderId}`;
}
export function notificationTargetUrl(
item: Pick<NotificationItem, "reason" | "target">,
): string | null {
const { target } = item;
if (target.kind === "queue") {
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
}
if (target.workOrderId == null) {
return null;
}
if (item.reason === "no-vendor") {
return chooseVendorUrl(target.workOrderId);
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
}

View file

@ -0,0 +1,60 @@
import {
NOTIFICATION_REASON_ORDER,
NOTIFICATION_SEVERITY_RANK,
type NotificationItem,
type NotificationSection,
} from "@/domain/notifications/types/notification-feed";
const reasonOrder: ReadonlyMap<string, number> = new Map(
NOTIFICATION_REASON_ORDER.map((reason, index) => [reason, index]),
);
function reasonIndex(reason: string): number {
return reasonOrder.get(reason) ?? NOTIFICATION_REASON_ORDER.length;
}
function highestSeverity(items: NotificationItem[]): NotificationItem["severity"] {
return items.reduce<NotificationItem["severity"]>(
(highest, item) =>
NOTIFICATION_SEVERITY_RANK[item.severity] > NOTIFICATION_SEVERITY_RANK[highest]
? item.severity
: highest,
"Low",
);
}
export function isDismissable(item: NotificationItem): boolean {
return item.rowType === "dismissable";
}
/**
* Applies this session's dismissals to the server feed: dismissed rows leave, section counts drop
* by the work orders those rows represented, empty sections disappear, and sections re-sort by
* their highest remaining severity with the fixed reason order breaking ties.
*/
export function visibleNotificationSections(
sections: NotificationSection[],
dismissedIds: ReadonlySet<string>,
): NotificationSection[] {
return sections
.map((section) => {
const items = section.items.filter(
(item) => !isDismissable(item) || !dismissedIds.has(item.id),
);
const dismissedCount = section.items
.filter((item) => !items.includes(item))
.reduce((total, item) => total + item.count, 0);
return {
...section,
items,
count: Math.max(section.count - dismissedCount, 0),
severity: highestSeverity(items),
};
})
.filter((section) => section.items.length > 0)
.sort(
(left, right) =>
NOTIFICATION_SEVERITY_RANK[right.severity] - NOTIFICATION_SEVERITY_RANK[left.severity] ||
reasonIndex(left.reason) - reasonIndex(right.reason),
);
}

View file

@ -1,18 +1,48 @@
import { apiGet } from "@/api/api"; import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths"; import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response"; import { handleApiResponse } from "@/api/handle-api-response";
import { mapServices } from "@/domain/services/mappers/service-mapper"; import {
import type { Service } from "@/domain/services/types/service"; mapCompletionDocTemplates,
mapService,
mapServices,
} from "@/domain/services/mappers/service-mapper";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const SERVICE_WORK_ORDER_TYPE_API = {
PM: 2,
Emergency: 3,
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = { const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
PM: WORK_ORDER_TYPE_API.PM, PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive, Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency, Emergency: WORK_ORDER_TYPE_API.Emergency,
}; };
function toApiInput(input: ServiceInput) {
return {
...input,
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
),
};
}
export const servicesApi = { export const servicesApi = {
getAll: async (isActive?: boolean): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: isActive === undefined ? undefined : { isActive },
});
return mapServices(handleApiResponse(data));
},
getActive: async (): Promise<Service[]> => { getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, { const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true }, searchParams: { isActive: true },
@ -27,4 +57,23 @@ export const servicesApi = {
}); });
return mapServices(handleApiResponse(data)); return mapServices(handleApiResponse(data));
}, },
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
return mapCompletionDocTemplates(handleApiResponse(data));
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
deactivate: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.services.deactivate(id));
},
}; };

View file

@ -1,62 +1,77 @@
import type { Service } from "@/domain/services/types/service"; import type {
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; CompletionDocTemplateOption,
LinkedServiceTemplate,
Service,
ServiceWorkOrderType,
} from "@/domain/services/types/service";
function asRecord(raw: unknown): Record<string, unknown> { function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}; return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
} }
function readId(item: Record<string, unknown>): string | number { function readString(record: Record<string, unknown>, ...keys: string[]): string {
const value = item.id ?? item.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readString(item: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) { for (const key of keys) {
const value = item[key]; const value = record[key];
if (typeof value === "string") return value.trim(); if (typeof value === "string") return value.trim();
if (typeof value === "number") return String(value);
} }
return ""; return "";
} }
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean { function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) { for (const key of keys) {
const value = item[key]; const value = record[key];
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
} }
return false; return false;
} }
function mapWorkOrderType(value: unknown): WOType | null { function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
if (typeof value === "number") { if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null; return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
} }
if (typeof value === "string") { if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance" return normalized === "pm" || normalized === "preventivemaintenance"
? "PM" ? "PM"
: normalized === "emergency" : normalized === "emergency"
? "Emergency" ? "Emergency"
: normalized === "reactive" : normalized === "reactive"
? "Reactive" ? "Reactive"
: null; : null;
} }
return null;
function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
if (!raw) return null;
const record = asRecord(raw);
const id = readId(record);
return id === "" ? null : { id, name: readString(record, "name", "Name") };
} }
export function mapService(raw: unknown): Service { export function mapService(raw: unknown): Service {
const item = asRecord(raw); const record = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes; const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
return { return {
id: readId(item), id: readId(record),
name: readString(item, "name", "Name"), name: readString(record, "name", "Name"),
trade: readString(item, "trade", "Trade"), trade: readString(record, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"), iconKey: readString(record, "iconKey", "IconKey"),
requiresCompletionDocument: readBool( requiresCompletionDocument: readBool(
item, record,
"requiresCompletionDocument", "requiresCompletionDocument",
"RequiresCompletionDocument", "RequiresCompletionDocument",
), ),
isActive: readBool(item, "isActive", "IsActive"), completionDocTemplate:
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
mapTemplate({
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
}),
isActive: readBool(record, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types) supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => { ? types.flatMap((value) => {
const mapped = mapWorkOrderType(value); const mapped = mapWorkOrderType(value);
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
} }
export function mapServices(raw: unknown): Service[] { export function mapServices(raw: unknown): Service[] {
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : []; const items = asRecord(raw);
const payload = Array.isArray(raw)
? raw
: (items.items ?? items.Items ?? items.data ?? items.Data);
return Array.isArray(payload)
? payload.map(mapService).filter((service) => service.id !== "")
: [];
}
export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
const payload = Array.isArray(raw)
? raw
: (() => {
const record = asRecord(raw);
return record.items ?? record.Items ?? record.data ?? record.Data;
})();
return Array.isArray(payload)
? payload.flatMap((item) => {
const record = asRecord(item);
const id = readId(record);
return id === ""
? []
: [
{
id,
name: readString(record, "name", "Name"),
} satisfies CompletionDocTemplateOption,
];
})
: [];
} }

View file

@ -1,4 +1,13 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
export interface LinkedServiceTemplate {
id: string | number;
name: string;
}
export type CompletionDocTemplateOption = LinkedServiceTemplate;
export interface Service { export interface Service {
id: string | number; id: string | number;
@ -6,6 +15,17 @@ export interface Service {
trade: string; trade: string;
iconKey: string; iconKey: string;
requiresCompletionDocument: boolean; requiresCompletionDocument: boolean;
completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean; isActive: boolean;
supportedWorkOrderTypes: WOType[]; supportedWorkOrderTypes: ServiceWorkOrderType[];
}
export interface ServiceInput {
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplateId: number | null;
supportedWorkOrderTypes: ServiceWorkOrderType[];
isActive?: boolean;
} }

View file

@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from "react";
import { useAuthContext } from "@/providers/auth-context";
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
import {
useCreateService,
useCompletionDocTemplates,
useDeactivateService,
useServices,
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
export const EMPTY_SERVICE_FORM: ServiceForm = {
name: "",
trade: "",
iconKey: "",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
Electrical: "zap",
"General Building & Handyman": "hammer",
};
export function buildServiceInput(
form: ServiceForm,
includeActiveState = false,
): {
input: ServiceInput | null;
error: string;
} {
const name = form.name.trim();
if (!name || !form.trade || !form.iconKey) {
return { input: null, error: "Service Name, Trade, and Icon are required." };
}
if (!form.supportedWorkOrderTypes.length) {
return { input: null, error: "Select at least one Work Order Type." };
}
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
return {
input: null,
error: "Select a completion document template when the document is required.",
};
}
const templateId = form.completionDocTemplateId;
const { isActive, ...editableFields } = form;
return {
input: {
...editableFields,
name,
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
},
error: "",
};
}
function emptyForm(): ServiceForm {
return {
...EMPTY_SERVICE_FORM,
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
};
}
function formFromService(service: Service): ServiceForm {
return {
name: service.name,
trade: service.trade,
iconKey: service.iconKey,
requiresCompletionDocument: service.requiresCompletionDocument,
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
isActive: service.isActive,
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
createService: ReturnType<typeof useCreateService>,
updateService: ReturnType<typeof useUpdateService>,
) {
const [selected, setSelected] = useState<Service | null>(null);
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
}
return canonicalTrades;
}, [canonicalTrades, form.trade]);
useEffect(() => {
if (!iconTouched && form.trade) {
setForm((current) => ({
...current,
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
}));
}
}, [form.trade, iconTouched]);
const openCreate = () => {
setSelected(null);
setForm(emptyForm());
setIconTouched(false);
setFormError("");
setEditorMode("add");
};
const openEdit = (service: Service) => {
setSelected(service);
setForm(formFromService(service));
setIconTouched(true);
setFormError("");
setEditorMode("edit");
};
const closeEditor = () => {
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
};
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
if (key === "trade") setIconTouched(false);
setFormError("");
setForm((current) => ({ ...current, [key]: value }));
};
const toggleType = (type: ServiceWorkOrderType) => {
setForm((current) => {
const supported = current.supportedWorkOrderTypes.includes(type)
? current.supportedWorkOrderTypes.filter((item) => item !== type)
: [...current.supportedWorkOrderTypes, type];
return { ...current, supportedWorkOrderTypes: supported };
});
};
const save = () => {
const validation = buildServiceInput(form, canDeactivate);
if (!validation.input) {
setFormError(validation.error);
return;
}
const input = validation.input;
if (editorMode === "add") {
createService.mutate(input, {
onSuccess: (created) => {
setEditorMode(null);
setSelected(created);
},
});
} else if (editorMode === "edit" && selected) {
updateService.mutate(
{ id: selected.id, input },
{
onSuccess: (updated) => {
setEditorMode(null);
setSelected(updated);
},
},
);
}
};
return {
selected,
setSelected,
editorMode,
form,
formError,
trades,
openCreate,
openEdit,
closeEditor,
updateForm,
toggleType,
save,
};
}
export function useServicesRegistryController() {
const { user } = useAuthContext();
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
const canDeactivate = isAdminUser(user?.userRoles);
const [status, setStatus] = useState<ServiceStatus>("active");
const [search, setSearch] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService);
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
const confirmDeactivate = () => {
if (!deactivateTarget) return;
deactivateService.mutate(deactivateTarget.id, {
onSuccess: () => {
setDeactivateTarget(null);
editor.setSelected(null);
},
});
};
return {
canManage,
canDeactivate,
status,
setStatus,
search,
setSearch,
selected: editor.selected,
setSelected: editor.setSelected,
editorMode: editor.editorMode,
form: editor.form,
formError: editor.formError,
trades: editor.trades,
templates,
filteredServices,
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate: editor.openCreate,
openEdit: editor.openEdit,
closeEditor: editor.closeEditor,
updateForm: editor.updateForm,
toggleType: editor.toggleType,
save: editor.save,
deactivateTarget,
setDeactivateTarget,
confirmDeactivate,
isSaving: createService.isPending || updateService.isPending,
isDeactivating: deactivateService.isPending,
};
}

View file

@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { servicesApi } from "@/domain/services/api/services-api";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(isActive),
queryFn: () => servicesApi.getAll(isActive),
staleTime: 60 * 1000,
});
}
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
return useQuery({
queryKey: queryKeys.services.completionTemplates(),
queryFn: servicesApi.getCompletionTemplates,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInput) => servicesApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service added");
},
onError: (error: Error) => toast.error(error.message || "Failed to add service"),
});
}
export function useUpdateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
servicesApi.update(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service updated");
},
onError: (error: Error) => toast.error(error.message || "Failed to update service"),
});
}
export function useDeactivateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => servicesApi.deactivate(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service deactivated");
},
onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
});
}

View file

@ -106,28 +106,61 @@ export const upliftsApi = {
id: string | number, id: string | number,
fallbackFileName = "uplift-evidence", fallbackFileName = "uplift-evidence",
): Promise<void> => { ): Promise<void> => {
let response: Response; const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
try {
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
const blob = await response.blob(); const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName); saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
const url = URL.createObjectURL(blob); },
const anchor = window.document.createElement("a");
anchor.href = url; /**
anchor.download = fileName; * Renders the evidence in `tab`, which the caller opens synchronously on click so
anchor.click(); * popup blockers allow it. Only inert types render inline on the app origin; any
URL.revokeObjectURL(url); * other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
*/
openEvidence: async (
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
tab?.close();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
return;
}
const url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
}, },
}; };
const INLINE_EVIDENCE_TYPES = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]);
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
}
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
"trade", "trade",
"Trade", "Trade",
), ),
technicianName: readString(item, "technicianName", "TechnicianName"),
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
attachmentCount: readNumber( attachmentCount: readNumber(
item, item,
"attachmentCount", "attachmentCount",

View file

@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
woNumber: string; woNumber: string;
site: string; site: string;
serviceName: string; serviceName: string;
technicianName: string;
workOrderDispatcherName: string;
workOrderScheduledDate: string;
attachmentCount: number | null; attachmentCount: number | null;
approvedOnWoAuto: number | null; approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null; approvedOnWoAdmin: number | null;

View file

@ -5,7 +5,6 @@ import type {
VendorAvailability, VendorAvailability,
VendorAssignmentPayload, VendorAssignmentPayload,
VendorInsight, VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations"; } from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations"; const base = "vendor-operations";
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
} }
export const vendorOperationsApi = { export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: { availability: async (params: {
start?: string; start?: string;
end?: string; end?: string;

View file

@ -1,28 +1,3 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability { export interface VendorAvailability {
id: number; id: number;
companyName: string; companyName: string;

View file

@ -0,0 +1,96 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
export interface NewCompanyTechnician {
contactName: string;
phone: string;
tradeSpecialties?: string;
}
export interface AddTechniciansToCompanyInput {
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
anchorVendorId: string | number;
technicians: NewCompanyTechnician[];
}
function takeAddedTechnician(
added: VendorRosterTechnician[],
contactName: string,
): VendorRosterTechnician {
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
if (index < 0) {
throw new Error("The technician was saved but could not be matched. Reload and try again.");
}
const [match] = added.splice(index, 1);
return match as VendorRosterTechnician;
}
/**
* Adds technicians to an existing vendor company through the additive roster PATCH.
*
* Work-order entry points used to POST a whole new vendor with blank company
* fields, which the create validator rejects (company phone/email) and which would
* overwrite company data. The additive path only appends technicians, keeps the
* company record untouched, and returns the persisted technician ids.
*/
export async function addTechniciansToCompany({
anchorVendorId,
technicians,
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
if (roster.companyId == null || roster.rowVersion === "") {
throw new Error("Vendor company could not be loaded. Try again.");
}
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
rowVersion: roster.rowVersion,
addTechnicians: technicians.map((technician) => {
const phone = technician.phone.trim();
return {
contactName: technician.contactName.trim(),
phone,
email: "",
tradeSpecialties: technician.tradeSpecialties ?? "",
isActive: true,
...(phone === "" ? {} : { preferredContact: "Phone" }),
};
}),
});
const added = saved.technicians.filter(
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
);
return technicians.map((technician) => {
const match = takeAddedTechnician(added, technician.contactName.trim());
const email = match.email.trim();
return {
id: match.id ?? "",
companyName: saved.name || roster.name,
contactName: match.contactName,
tradeSpecialties: match.tradeSpecialties,
address: saved.address,
distanceMiles: null,
email: email || undefined,
};
});
}
export function useAddTechniciansToCompany(): UseMutationResult<
VendorDropdownItem[],
Error,
AddTechniciansToCompanyInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTechniciansToCompany,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
});
}

View file

@ -1,21 +0,0 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
},
});
}

View file

@ -216,10 +216,14 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number, workOrderId: string | number,
mediaId: string | number, mediaId: string | number,
): Promise<Blob> => { ): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw( const response = await apiRequestRaw(
"get", "get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId), API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false }, { throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent", "workOrderBoardDocumentsApi.getMediaContent",
); );

View file

@ -0,0 +1,81 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiRequestRaw } from "@/api/api";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderBoardPatchPayload,
WorkOrderPocPatchPayload,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/** Shared request/error handling for board row PATCH endpoints (board + poc). */
async function patchBoardRow(
operationName: string,
method: "patch",
url: string,
payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> {
const response = await apiRequestRaw(
method,
url,
{ json: payload, throwHttpErrors: false },
operationName,
);
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.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);
}
export const workOrderBoardPatchApi = {
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow(
"workOrdersApi.patchBoardField",
"patch",
API_PATHS.workOrder.boardPatch(id),
payload,
),
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: async (
id: string | number,
payload: WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
};

View file

@ -1,12 +1,8 @@
import { API_PATHS } from "@/api/api-paths"; import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; import { 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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api"; import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
import { import {
mapDispatchComment, mapDispatchComment,
mapDispatchDetail, mapDispatchDetail,
@ -20,7 +16,6 @@ import {
mapWorkOrderDetail, mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload, mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData, mapWorkOrderFormToUpdateFormData,
mapWorkOrderTableRow,
mapWorkOrderTableRows, mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper"; } from "@/domain/work-orders/mappers/work-order-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
@ -42,7 +37,6 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type { import type {
WorkOrderBoardListResult, WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams, WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi, WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams, WorkOrderBoardSearchQueryParams,
@ -310,51 +304,10 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data)); return mapWorkOrderDetail(handleApiResponse(data));
}, },
patchBoardField: async ( patchBoardField: workOrderBoardPatchApi.patchBoardField,
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
let data: unknown; /** Manual POC override — same row/error contract as the board patch. */
try { updatePoc: workOrderBoardPatchApi.updatePoc,
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record =
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.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);
},
update: async ( update: async (
id: string | number, id: string | number,

View file

@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"techPhone", "techPhone",
"location", "location",
"locationId", "locationId",
"pocName",
"pocPhone",
"pocNotes",
"title", "title",
"priority", "priority",
"apptStart", "apptStart",
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key)); return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
} }
/** Composite manual POC override op routed to PATCH …/poc. */
export type BoardPocOp = {
kind: "poc";
pocName?: string;
pocPhone?: string;
pocNotes?: string;
};
export type BoardOp = BoardFieldOp | BoardPocOp;
function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
const hasPocFields =
patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined;
if (!hasPocFields) {
return [];
}
return [
{
kind: "poc",
pocName: patch.pocName,
pocPhone: patch.pocPhone,
pocNotes: patch.pocNotes,
},
];
}
/** /**
* Expands a UI table patch into ordered board field ops (one PATCH each). * Expands a UI table patch into ordered board ops (one request each).
* Schedule/week-only fields are emitted first in a stable order. * Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/ */
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] { export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) { if (isLocalOnlyTablePatch(patch)) {
return []; return [];
} }
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)]; return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
} }
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean { export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
primaryDispatchId?: number | null; primaryDispatchId?: number | null;
} }
/** Payload for PATCH /workorders/{id}/poc (manual POC override). */
export interface WorkOrderPocPatchPayload {
pocName: string;
pocPhone: string;
pocNotes: string;
workOrderVersion: string;
}
export type WorkOrderBoardEditableField = export type WorkOrderBoardEditableField =
| "woNumber" | "woNumber"
| "workOrderType" | "workOrderType"

View file

@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export type WizardSchedMode = "date" | "week" | "range"; export type WizardSchedMode = "date" | "week" | "range";
export interface WizardNewTechnician {
name: string;
phone: string;
}
export interface WorkOrderWizardDraft { export interface WorkOrderWizardDraft {
locationId: string; locationId: string;
locationName: string; locationName: string;
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
avetaRequired: boolean; avetaRequired: boolean;
vendorId: string; vendorId: string;
vendorName: string; vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string; techPhone: string;
vendorNotes: string; vendorNotes: string;
apptStart: string; apptStart: string;
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
avetaRequired: false, avetaRequired: false,
vendorId: "", vendorId: "",
vendorName: "", vendorName: "",
vendorCompany: "",
newTechnicians: [],
techPhone: "", techPhone: "",
vendorNotes: "", vendorNotes: "",
apptStart: "", apptStart: "",

View file

@ -14,6 +14,7 @@ import {
buildBoardPatchPayload, buildBoardPatchPayload,
expandTablePatchToBoardFieldOps, expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch, isLocalOnlyTablePatch,
type BoardOp,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type { import type {
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult = type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow }; { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
return "kind" in op && op.kind === "poc";
}
async function applyBoardOp(
id: string | number,
op: BoardOp,
row: WorkOrderTableRow,
): Promise<WorkOrderTableRow> {
if (isPocOp(op)) {
if (!row.rowVersion) {
throw new Error("workOrderVersion is required for board patch");
}
return workOrdersApi.updatePoc(id, {
pocName: op.pocName ?? row.pocName,
pocPhone: op.pocPhone ?? row.pocPhone,
pocNotes: op.pocNotes ?? row.pocNotes,
workOrderVersion: row.rowVersion,
});
}
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
}
async function applyBoardFieldOps( async function applyBoardFieldOps(
id: string | number, id: string | number,
patch: WorkOrderTablePatch, patch: WorkOrderTablePatch,
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
let completedOps = 0; let completedOps = 0;
for (const op of ops) { for (const op of ops) {
try { try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest)); latest = await applyBoardOp(id, op, latest);
completedOps += 1; completedOps += 1;
} catch (error) { } catch (error) {
if (completedOps > 0) { if (completedOps > 0) {

View file

@ -7,7 +7,8 @@ import type {
/** /**
* Fields compared for dirty state and included in Save patch. * Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName). * Only keys that map to board PATCH (or required companions like dispatcherName).
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op. * Primary POC scalars persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field). * additionalContacts is board-editable (SH-218 PATCH field).
*/ */
export const SLIDE_OVER_EDIT_KEYS = [ export const SLIDE_OVER_EDIT_KEYS = [
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
"vendorId", "vendorId",
// company/tech are display companions; board persists vendor via vendorId only // company/tech are display companions; board persists vendor via vendorId only
"status", "status",
"pocName",
"pocPhone",
"pocNotes",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>; ] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number]; type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];

View file

@ -0,0 +1,48 @@
import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown-links";
const WORK_ORDER_PARAM = "wo";
const TAB_PARAM = "tab";
export const WORK_ORDER_DEEP_LINK_TABS = [
"info",
"comments",
"audit",
"completion",
"extras",
] as const;
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
export type WorkOrderDeepLink = {
workOrderId: string;
tab: WorkOrderDeepLinkTab;
};
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
}
/** `/workorders?wo=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
if (isDeepLinkTab(tab ?? null)) {
search.set(TAB_PARAM, String(tab));
}
return `${WORK_ORDERS_ROUTE}?${search.toString()}`;
}
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
if (!/^\d+$/.test(workOrderId)) {
return null;
}
const tab = params.get(TAB_PARAM);
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
}
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(params);
next.delete(WORK_ORDER_PARAM);
next.delete(TAB_PARAM);
return next;
}

View file

@ -75,6 +75,8 @@ export const queryKeys = {
}, },
services: { services: {
all: ["services"] as const, all: ["services"] as const,
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const, active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) => byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const, [...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
@ -147,6 +149,10 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const, detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
sites: () => [...queryKeys.assets.all, "sites"] as const, sites: () => [...queryKeys.assets.all, "sites"] as const,
}, },
notifications: {
all: ["notifications"] as const,
feed: () => [...queryKeys.notifications.all, "feed"] as const,
},
vendorPortal: { vendorPortal: {
all: ["vendorPortal"] as const, all: ["vendorPortal"] as const,
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const, session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,

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