Merge branch 'main' into refactor/dispatch-detail-modal-split-v2
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!--
|
||||
Title: type(scope): description (SH-123)
|
||||
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
|
||||
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
|
||||
Docs-only and configuration-only chores may omit the key.
|
||||
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
|
||||
Scope: one logical change per PR. If the title needs "and", split it.
|
||||
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
|
||||
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why, in plain language. Two to four sentences. -->
|
||||
|
||||
## Changes and value
|
||||
|
||||
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
|
||||
|
||||
## Ticket
|
||||
|
||||
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->
|
||||
21
.github/renovate.json
vendored
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"],
|
||||
"schedule": ["before 6am every weekday"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
|
|
@ -14,6 +15,17 @@
|
|||
"datasourceTemplate": "npm",
|
||||
"depNameTemplate": "@playwright/test",
|
||||
"versioningTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": [
|
||||
"actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard"
|
||||
],
|
||||
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
|
||||
"matchStrings": ["ACTIONLINT_VERSION: \"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
|
||||
"datasourceTemplate": "github-releases",
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
|
|
@ -36,6 +48,13 @@
|
|||
"dependencyDashboardApproval": true,
|
||||
"groupName": "playwright"
|
||||
},
|
||||
{
|
||||
"description": [
|
||||
"Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version"
|
||||
],
|
||||
"matchPackageNames": ["rhysd/actionlint"],
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": ["Keep MUI packages together"],
|
||||
"matchPackageNames": ["@mui/**"],
|
||||
|
|
|
|||
56
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -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
|
||||
161
.github/workflows/ci.yaml
vendored
|
|
@ -3,6 +3,9 @@ name: Frontend checks
|
|||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
# The merge queue builds main plus the queued pull requests on a temporary
|
||||
# branch and only counts checks that ran on the merge_group event.
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
|
@ -10,28 +13,109 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test
|
||||
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
static:
|
||||
name: static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
- run: npm run lint
|
||||
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
unit:
|
||||
name: unit
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test -- --shard=${{ matrix.shard }}/4
|
||||
|
||||
visual:
|
||||
name: Visual regression
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e:visual
|
||||
- name: Upload visual diff artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-regression-diffs
|
||||
path: |
|
||||
test-results/visual
|
||||
playwright-report-visual
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
browser-smoke:
|
||||
name: browser-smoke
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e
|
||||
- name: Upload smoke artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: browser-smoke-artifacts
|
||||
path: |
|
||||
test-results
|
||||
playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
governance:
|
||||
# Repo-owned guarantee that every frontend quality gate runs from this
|
||||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan
|
||||
# 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`.
|
||||
# Repo-owned gates: godfile ratchet, changed-file maintainability,
|
||||
# Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront
|
||||
# verify, GitHub workflow shell, isolation classifier tests, and live G13
|
||||
# (pull_request, merge_group per queued PR, and local). Format, lint, build,
|
||||
# and unit tests run
|
||||
# in the parallel jobs above, not here.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
# merge group -> the group's own base (github.event.merge_group.base_sha)
|
||||
# push-> the previous commit on the branch (github.event.before)
|
||||
# manual -> main, for exact-head recovery runs
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -46,10 +130,13 @@ jobs:
|
|||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
|
||||
base="${PR_BASE_SHA}"
|
||||
elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then
|
||||
base="${MERGE_GROUP_BASE_SHA}"
|
||||
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
|
|
@ -79,29 +166,29 @@ jobs:
|
|||
tar -xzf actionlint.tar.gz actionlint
|
||||
sudo mv actionlint /usr/local/bin/actionlint
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
- run: npm run governance
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
if: always()
|
||||
needs: [static, build, unit, visual, browser-smoke, governance]
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e:visual
|
||||
- name: Upload visual diff artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-regression-diffs
|
||||
path: |
|
||||
test-results/visual
|
||||
playwright-report-visual
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
- name: All required jobs passed
|
||||
shell: bash
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for result in ${RESULTS}; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
echo "Required jobs did not all succeed: ${RESULTS}"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
1
.github/workflows/deploy-web.yaml
vendored
|
|
@ -28,7 +28,6 @@ on:
|
|||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- ".github/workflows/ci.yaml"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/deploy-web.yaml"
|
||||
release:
|
||||
types: [published]
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -49,3 +49,6 @@ seed-data.sql
|
|||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# local scratch
|
||||
/tmp/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`npm run verify` in a repo-owned `governance` job, so a green local run mirrors
|
||||
CI.
|
||||
gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)
|
||||
(`static`, `build`, `unit`, `visual`, `browser-smoke`, `governance`) with
|
||||
`ci-complete` failing if any of those jobs did not succeed.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,24 +14,24 @@ isolation). A task is not done until this is green.
|
|||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` |
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -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 +
|
||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow.
|
||||
- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):**
|
||||
fmt, `init -backend=false`, validate, import-plan unit tests, and G13
|
||||
classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
|
||||
repository owns every job. `static` (`format:check` + `lint`), `build`
|
||||
(`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
|
||||
(Playwright visual), `browser-smoke` (`npm run test:e2e`), and `governance`
|
||||
(`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete`
|
||||
fails unless all of those jobs succeeded and is the required merge-queue
|
||||
check. Live G13 runs on
|
||||
`pull_request` (merge-base range), `merge_group` (each queued PR as a
|
||||
first-parent commit), and locally. It skips `push`. A Terraform-only PR
|
||||
stacked with an app-only PR still passes; a mixed change set still fails.
|
||||
Terraform fmt/validate and the related unit tests run inside `governance` on
|
||||
every event.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
|
|
|
|||
41
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
|
||||

|
||||

|
||||
|
|
@ -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).
|
||||
|
||||
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
|
||||
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
|
||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||
|
||||
## Architecture
|
||||
|
|
@ -122,19 +123,23 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
|
||||
## Contributing
|
||||
|
||||
- Branch from `dev` with a kebab-case description and a prefix matching the
|
||||
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
- Branch from `main` with a kebab-case description and a prefix matching the
|
||||
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
||||
- Commit messages follow
|
||||
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||
rejects anything else at commit time.
|
||||
- Open PRs against `main`. Protected branches need a green CI run and an
|
||||
approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- PRs cannot mix `terraform/` with deployable application files (G13). 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.
|
||||
- Open PRs against `main`. The PR body uses the three-section layout the
|
||||
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
|
||||
`ci-complete` check and an approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
|
||||
approvals. PRs merge through the merge queue, so a branch does not need to
|
||||
be updated with `main` before it merges. Merged branches are deleted
|
||||
automatically.
|
||||
- A change set cannot mix `terraform/` with deployable application files (G13),
|
||||
including each queued PR on the merge-group check. Workflow, docs, and
|
||||
gate-script changes may travel with either side. `deploy-web.yaml` still
|
||||
ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
|
||||
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
|
||||
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
|
||||
prod distribution exists.
|
||||
|
|
@ -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:
|
||||
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)
|
||||
and the Terraform gates) is guaranteed from this repository. Conventions
|
||||
and gates are documented under
|
||||
and PRs to `main`, runs format, lint, build, sharded unit tests, visual
|
||||
regression, Playwright smoke, and `npm run governance` as parallel jobs, then
|
||||
`ci-complete`.
|
||||
Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **Terraform 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))
|
||||
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
|
||||
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
|
||||
|
|
|
|||
|
|
@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`,
|
|||
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
|
||||
fails G13. Workflow, docs, and gate-script changes may travel with either
|
||||
side.
|
||||
|
||||
## 8. PR description contract (minimal)
|
||||
|
||||
- **Summary** — what changed and why, in plain language.
|
||||
- **Changes and value** — grouped by area, each with the value it delivers.
|
||||
- **Ticket** — the board key(s) when one applies; "None" otherwise.
|
||||
- Link any ADR relied upon.
|
||||
|
||||
Avoid boilerplate: no deployment notes, no validation transcripts, no
|
||||
"residual-risk" theatre, no AI signatures. The validation story lives in the
|
||||
check run results and the close-out, not in the PR body.
|
||||
|
||||
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
|
||||
template, whose Summary / Validation / Tests / Notes headings this repository
|
||||
does not use. The org `callable-pr-policy` workflow hard-codes those four
|
||||
headings; it is not wired into this repository, and this layout is the reason.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 191 KiB |
85
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
|
|||
.toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||
const workOrder = { kind: "workOrder", tab: "info" };
|
||||
await page.route("**/api/notifications", (route) =>
|
||||
fulfillJson(route, {
|
||||
items: [
|
||||
generatedAt: "2026-07-23T12:00:00Z",
|
||||
sections: [
|
||||
{
|
||||
id: "no-vendor-501",
|
||||
type: "NoVendor",
|
||||
reason: "no-vendor",
|
||||
label: "No Vendor",
|
||||
severity: "Medium",
|
||||
group: "No Vendor",
|
||||
title: "No vendor assigned — 48h to service",
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
scheduledAt: "2026-07-25T12:00:00Z",
|
||||
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
||||
},
|
||||
{
|
||||
id: "vendor-conflict-801-802",
|
||||
type: "VendorConflict",
|
||||
severity: "Medium",
|
||||
group: "Vendor Conflict",
|
||||
title: "Vendor has overlapping appointments",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
workOrders: [
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T13:00:00Z",
|
||||
end: "2026-07-25T15:00:00Z",
|
||||
id: "no-vendor-501",
|
||||
reason: "no-vendor",
|
||||
severity: "Medium",
|
||||
rowType: "dismissable",
|
||||
title: "WO #WO-501 starts within 48h with no vendor assigned",
|
||||
count: 1,
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
target: { ...workOrder, workOrderId: 501 },
|
||||
workOrders: [{ id: 501, number: "WO-501" }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ label: "WO-501", href: "/workorders/501" },
|
||||
{ label: "WO-502", href: "/workorders/502" },
|
||||
},
|
||||
{
|
||||
reason: "vendor-conflict",
|
||||
label: "Vendor Conflict",
|
||||
severity: "Medium",
|
||||
count: 2,
|
||||
items: [
|
||||
{
|
||||
id: "vendor-conflict-501-502",
|
||||
reason: "vendor-conflict",
|
||||
severity: "Medium",
|
||||
rowType: "dismissable",
|
||||
title: "Acme has overlapping appointments",
|
||||
count: 2,
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
target: { ...workOrder, workOrderId: 501 },
|
||||
workOrders: [
|
||||
{ id: 501, number: "WO-501" },
|
||||
{ id: 502, number: "WO-502" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
|
|||
|
||||
await page.goto("/notifications");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/edit/501?assignVendor=1",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/501",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/502",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
|
||||
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
|
||||
});
|
||||
|
||||
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ function multipartBoundary(request: Request): string | undefined {
|
|||
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
||||
}
|
||||
|
||||
function multipartFileName(body: string): string | undefined {
|
||||
return /filename="([^"]+)"/.exec(body)?.[1];
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
|
|
@ -141,7 +145,7 @@ test.describe("Completion uploads on a mobile viewport", () => {
|
|||
const request = route.request();
|
||||
const body = request.postDataBuffer()?.toString("latin1") ?? "";
|
||||
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 };
|
||||
media.push(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 chooser = page.waitForEvent("filechooser");
|
||||
await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles([
|
||||
const files = [
|
||||
{ name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG },
|
||||
{ name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 },
|
||||
{ name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV },
|
||||
]);
|
||||
];
|
||||
await (await chooser).setFiles(files);
|
||||
|
||||
await expect.poll(() => uploads.length).toBe(3);
|
||||
for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) {
|
||||
expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
expect(uploads[index]!.body).toContain(`filename="${name}"`);
|
||||
await expect.poll(() => uploads.length).toBe(files.length);
|
||||
for (const { name } of files) {
|
||||
const upload = uploads.find((item) => multipartFileName(item.body) === name);
|
||||
expect(upload, `multipart upload for ${name}`).toBeTruthy();
|
||||
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
}
|
||||
|
||||
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
|
||||
|
|
|
|||
265
e2e/work-orders/inline-technician.spec.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Technicians registered inline from work-order entry points
|
||||
// become real vendor records only on save, through the additive vendor-company roster
|
||||
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-inline-technician-e2e-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 41,
|
||||
companyName: "Vinewood LLC",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
const ROSTER = {
|
||||
companyId: 7,
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
name: "Vinewood LLC",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "",
|
||||
address: "1 Market St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
notes: "",
|
||||
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
|
||||
};
|
||||
|
||||
const BOARD_ROW = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
workOrderType: 2,
|
||||
siteCode: "STL-01",
|
||||
locationName: "St. Louis HQ",
|
||||
locationId: 10,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
vendorId: 41,
|
||||
vendorName: "Vinewood LLC",
|
||||
techName: "Adam Whyte",
|
||||
title: "Quarterly HVAC PM",
|
||||
rowVersion: "rv-1",
|
||||
};
|
||||
|
||||
type Captured = { method: string; pathname: string; body: unknown };
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function bodyOf(request: Request): unknown {
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
|
||||
const added = (body.addTechnicians ?? []).map((technician, index) => ({
|
||||
...technician,
|
||||
id: 42 + index,
|
||||
}));
|
||||
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
|
||||
}
|
||||
|
||||
async function mockApi(page: Page, writes: Captured[]) {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (!pathname.startsWith("/api/")) return route.fallback();
|
||||
const method = request.method();
|
||||
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
|
||||
|
||||
if (pathname.includes("/workorders/board/search")) {
|
||||
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "GET") {
|
||||
return fulfillJson(route, {
|
||||
weekStart: "2026-08-17",
|
||||
weekEnd: "2026-08-21",
|
||||
counts: { returned: 1, total: 1 },
|
||||
unscheduled: [],
|
||||
scheduled: [BOARD_ROW],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/workorders/board" && method === "POST") {
|
||||
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
|
||||
}
|
||||
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
|
||||
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster" && method === "GET") {
|
||||
return fulfillJson(route, ROSTER);
|
||||
}
|
||||
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
|
||||
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
|
||||
}
|
||||
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
|
||||
return fulfillJson(route, { data: VENDORS });
|
||||
}
|
||||
if (pathname.includes("/locations/sites")) {
|
||||
return fulfillJson(route, {
|
||||
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
|
||||
});
|
||||
}
|
||||
if (pathname === "/api/locations/10") {
|
||||
return fulfillJson(route, {
|
||||
data: {
|
||||
id: 10,
|
||||
name: "St. Louis HQ",
|
||||
siteCode: "STL-01",
|
||||
contact: "Pat Site",
|
||||
phone: "3145550111",
|
||||
},
|
||||
});
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
function vendorWrites(writes: Captured[]) {
|
||||
return writes.filter(
|
||||
(write) =>
|
||||
write.pathname.startsWith("/api/vendors") ||
|
||||
write.pathname.startsWith("/api/vendor-company-roster"),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWizardVendorStep(page: Page) {
|
||||
await page.goto("/workorders");
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /select company/i }).click();
|
||||
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
|
||||
await page.getByRole("button", { name: "+ Add technician" }).click();
|
||||
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
|
||||
await page.getByLabel("New Technician 1 phone").fill("3145550199");
|
||||
}
|
||||
|
||||
test.describe("Inline technician registration", () => {
|
||||
test("wizard persists the technician under the company on create and assigns it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
// Staging a technician is local only.
|
||||
expect(vendorWrites(writes)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "Create work order" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
|
||||
.toBeTruthy();
|
||||
expect(vendorWrites(writes)).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
pathname: "/api/vendor-company-roster/7",
|
||||
body: {
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
addTechnicians: [
|
||||
{
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-0199",
|
||||
email: "",
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const create = writes.find((write) => write.pathname === "/api/workorders/board");
|
||||
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
|
||||
});
|
||||
|
||||
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await openWizardVendorStep(page);
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
|
||||
|
||||
// Reopening starts clean: the staged technician was discarded, not persisted.
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: /select site/i }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /STL-01/ })
|
||||
.last()
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: Captured[] = [];
|
||||
await mockApi(page, writes);
|
||||
await page.goto("/workorders");
|
||||
|
||||
await page
|
||||
.locator("#wo-row-1")
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
|
||||
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
|
||||
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
|
||||
await dialog.getByRole("button", { name: /^add$/i }).click();
|
||||
await dialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect
|
||||
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
|
||||
.toBeTruthy();
|
||||
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
|
||||
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
|
||||
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
|
||||
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
|
||||
expect(JSON.stringify(boardPatch?.body)).toContain("42");
|
||||
});
|
||||
});
|
||||
|
|
@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
|
|||
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /Select company/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Gateway Plumbing/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.getByRole("button", { name: /Select technician/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /Adam Whyte/ })
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||
"test:github-workflows": "bash scripts/check-github-workflows.sh",
|
||||
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py",
|
||||
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default defineConfig({
|
|||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: {
|
||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default defineConfig({
|
|||
fullyParallel: false,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
workers: 2,
|
||||
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
|
||||
outputDir: "test-results/visual",
|
||||
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
|
||||
|
|
|
|||
29
scripts/g13-live-isolation.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Live G13 scheduling and how a GitHub event is split into change sets.
|
||||
*
|
||||
* Isolation is a per-change rule. pull_request and local runs classify the
|
||||
* merge-base...HEAD range (one PR). merge_group classifies each first-parent
|
||||
* commit vs its parent (one queued PR per squash or merge-commit). The group
|
||||
* union is not a change set: a Terraform-only PR stacked with an app-only PR
|
||||
* must still pass. push is not classified; landing already happened.
|
||||
*/
|
||||
|
||||
export function shouldRunLiveIsolation(eventName) {
|
||||
return !eventName || eventName === "pull_request" || eventName === "merge_group";
|
||||
}
|
||||
|
||||
export function usesPerCommitIsolation(eventName) {
|
||||
return eventName === "merge_group";
|
||||
}
|
||||
|
||||
/**
|
||||
* File lists to run through check_app_terraform_isolation.py.
|
||||
* `commitDiffs` is first-parent order (oldest first); ignored except on
|
||||
* merge_group.
|
||||
*/
|
||||
export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) {
|
||||
if (usesPerCommitIsolation(eventName)) {
|
||||
return commitDiffs.map((commit) => commit.files);
|
||||
}
|
||||
return [rangeFiles];
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
||||
|
|
@ -218,15 +220,79 @@ function runRepositoryGate(label, script) {
|
|||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function runIsolationGate(baseRef) {
|
||||
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
|
||||
function classifyIsolationPaths(files) {
|
||||
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
|
||||
cwd: ROOT,
|
||||
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() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
|
|
@ -326,23 +392,30 @@ function main() {
|
|||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`);
|
||||
if (!baseRef) {
|
||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||
if (!shouldRunLiveIsolation(eventName)) {
|
||||
console.log(
|
||||
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
|
||||
);
|
||||
} else if (!baseRef) {
|
||||
console.log("G13: application and Terraform isolation (no base...HEAD)");
|
||||
console.log(" FAIL (no valid base ref)");
|
||||
failures.push(
|
||||
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||
);
|
||||
} else {
|
||||
const isolation = runIsolationGate(baseRef);
|
||||
if (isolation.error) {
|
||||
console.log(" FAIL (could not start)");
|
||||
failures.push(`G13: could not start: ${isolation.error.message}`);
|
||||
} else {
|
||||
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
|
||||
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
|
||||
if (isolation.status !== 0) {
|
||||
failures.push("G13: do not mix deployable application files with terraform/");
|
||||
}
|
||||
let isolation;
|
||||
try {
|
||||
isolation = runIsolationGate(baseRef, eventName);
|
||||
} catch (error) {
|
||||
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
|
||||
console.log(" FAIL (could not resolve isolation diffs)");
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
|
||||
}
|
||||
if (isolation) {
|
||||
logIsolationGate(baseRef, isolation);
|
||||
recordIsolationFailures(isolation, failures);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
73
scripts/test-g13-live-isolation.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
liveIsolationFileSets,
|
||||
shouldRunLiveIsolation,
|
||||
usesPerCommitIsolation,
|
||||
} from "./g13-live-isolation.mjs";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py");
|
||||
const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"];
|
||||
const APP_ONLY = ["src/app/routes.tsx"];
|
||||
|
||||
function classify(files) {
|
||||
const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" });
|
||||
return result.status;
|
||||
}
|
||||
|
||||
function anySetFails(fileSets) {
|
||||
return fileSets.some((files) => classify(files) !== 0);
|
||||
}
|
||||
|
||||
describe("shouldRunLiveIsolation", () => {
|
||||
it("runs locally and on pull_request", () => {
|
||||
assert.equal(shouldRunLiveIsolation(undefined), true);
|
||||
assert.equal(shouldRunLiveIsolation(""), true);
|
||||
assert.equal(shouldRunLiveIsolation("pull_request"), true);
|
||||
});
|
||||
|
||||
it("runs on merge_group so the required check classifies the candidate", () => {
|
||||
assert.equal(shouldRunLiveIsolation("merge_group"), true);
|
||||
assert.equal(usesPerCommitIsolation("merge_group"), true);
|
||||
});
|
||||
|
||||
it("skips push and other CI events", () => {
|
||||
assert.equal(shouldRunLiveIsolation("push"), false);
|
||||
assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false);
|
||||
assert.equal(usesPerCommitIsolation("pull_request"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveIsolationFileSets", () => {
|
||||
it("classifies the PR range as one change set", () => {
|
||||
const range = [...TERRAFORM_ONLY, ...APP_ONLY];
|
||||
const sets = liveIsolationFileSets("pull_request", range, [
|
||||
{ files: TERRAFORM_ONLY },
|
||||
{ files: APP_ONLY },
|
||||
]);
|
||||
assert.deepEqual(sets, [range]);
|
||||
assert.equal(anySetFails(sets), true);
|
||||
});
|
||||
|
||||
it("classifies each queued PR, not the merge-group union", () => {
|
||||
const union = [...TERRAFORM_ONLY, ...APP_ONLY];
|
||||
const sets = liveIsolationFileSets("merge_group", union, [
|
||||
{ files: TERRAFORM_ONLY },
|
||||
{ files: APP_ONLY },
|
||||
]);
|
||||
assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]);
|
||||
assert.equal(anySetFails(sets), false);
|
||||
assert.equal(classify(union), 1);
|
||||
});
|
||||
|
||||
it("fails a single queued PR that mixes terraform and app files", () => {
|
||||
const mixed = [...TERRAFORM_ONLY, ...APP_ONLY];
|
||||
const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]);
|
||||
assert.equal(anySetFails(sets), true);
|
||||
});
|
||||
});
|
||||
|
|
@ -30,7 +30,9 @@ export const API_PATHS = {
|
|||
boardCreate: "workorders/board",
|
||||
boardSearch: "workorders/board/search",
|
||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||
completionTemplates: "workorders/completion-templates",
|
||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||
`workorders/${workOrderId}/comments/${commentId}`,
|
||||
|
|
@ -167,6 +169,8 @@ export const API_PATHS = {
|
|||
},
|
||||
services: {
|
||||
list: "services",
|
||||
byId: (id: string | number) => `services/${id}`,
|
||||
deactivate: (id: string | number) => `services/${id}/deactivate`,
|
||||
},
|
||||
user: {
|
||||
list: "User",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
|||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { getSidebarWidth } from "@/config/menu";
|
||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
const theme = useTheme();
|
||||
|
|
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
|
|||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<AppSidebar
|
||||
collapsed={isDesktop ? navCollapsed : false}
|
||||
mobile={!isDesktop}
|
||||
open={isDesktop || mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
<NotificationSessionProvider>
|
||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<AppSidebar
|
||||
collapsed={isDesktop ? navCollapsed : false}
|
||||
mobile={!isDesktop}
|
||||
open={isDesktop || mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className="flex-1 overflow-auto"
|
||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<Outlet />
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className="flex-1 overflow-auto"
|
||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NotificationSessionProvider>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,26 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
import { Box } from "@mui/material";
|
||||
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
|
||||
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
|
||||
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["vendor-operations", "notifications"],
|
||||
queryFn: vendorOperationsApi.notifications,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const groups = data.reduce((result, item) => {
|
||||
const items = result.get(item.group) ?? [];
|
||||
items.push(item);
|
||||
result.set(item.group, items);
|
||||
return result;
|
||||
}, new Map<string, typeof data>());
|
||||
const center = useNotificationCenter();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
Items requiring attention
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Medium-severity vendor reminders refresh automatically. They do not create banners or
|
||||
toasts.
|
||||
</Typography>
|
||||
<Box className="flex flex-col gap-2 p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title="Notifications"
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationFeedActions center={center} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Box className="max-w-3xl px-5 pb-5">
|
||||
<NotificationFeedList center={center} />
|
||||
</Box>
|
||||
{isLoading && <CircularProgress aria-label="Loading notifications" />}
|
||||
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Typography>No vendor alerts require attention.</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{[...groups.entries()].map(([group, items]) => (
|
||||
<Paper key={group} variant="outlined" className="p-4">
|
||||
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
|
||||
<Typography variant="h6">{group}</Typography>
|
||||
<Chip label={items.length} size="small" />
|
||||
</Stack>
|
||||
<Stack spacing={2}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" className="p-3">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
sx={{ gap: 2, justifyContent: "space-between" }}
|
||||
>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip label={item.severity} color="warning" size="small" />
|
||||
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-1">
|
||||
{item.workOrderNumber ??
|
||||
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
|
||||
</Typography>
|
||||
{item.scheduledAt != null && (
|
||||
<Typography variant="caption">
|
||||
Scheduled {new Date(item.scheduledAt).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
{item.action != null && (
|
||||
<Button component={Link} to={item.action.href} variant="contained">
|
||||
{item.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{item.actions?.map((action) => (
|
||||
<Button
|
||||
key={action.href}
|
||||
component={Link}
|
||||
to={action.href}
|
||||
variant="outlined"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Drawer,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
|
||||
import {
|
||||
SERVICE_WORK_ORDER_TYPES,
|
||||
type CompletionDocTemplateOption,
|
||||
type Service,
|
||||
type ServiceWorkOrderType,
|
||||
} from "@/domain/services/types/service";
|
||||
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
|
||||
|
||||
const ICONS = [
|
||||
{ key: "wrench", label: "Wrench", Icon: Wrench },
|
||||
{ key: "droplets", label: "Droplets", Icon: Droplets },
|
||||
{ key: "zap", label: "Zap", Icon: Zap },
|
||||
{ key: "hammer", label: "Hammer", Icon: Hammer },
|
||||
{ key: "settings", label: "Settings", Icon: Settings2 },
|
||||
] as const;
|
||||
|
||||
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
|
||||
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
|
||||
const Icon = icon.Icon;
|
||||
return <Icon size={size} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
type DetailsProps = {
|
||||
service: Service | null;
|
||||
open: boolean;
|
||||
canManage: boolean;
|
||||
canDeactivate: boolean;
|
||||
onClose: () => void;
|
||||
onEdit: (service: Service) => void;
|
||||
onDeactivate: (service: Service) => void;
|
||||
};
|
||||
|
||||
export function ServiceDetailsDrawer({
|
||||
service,
|
||||
open,
|
||||
canManage,
|
||||
canDeactivate,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDeactivate,
|
||||
}: DetailsProps) {
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
{service !== null && (
|
||||
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
|
||||
<Box className="flex items-start justify-between p-5">
|
||||
<Box>
|
||||
<Typography variant="h6" className="font-display font-bold">
|
||||
{service.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Service details
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box className="flex-1 overflow-y-auto p-5">
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ServiceIcon iconKey={service.iconKey} size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Trade
|
||||
</Typography>
|
||||
<Typography variant="body1" className="font-semibold">
|
||||
{service.trade}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-semibold">
|
||||
Work Order Types
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||
{service.supportedWorkOrderTypes.map((type) => (
|
||||
<Chip key={type} size="small" label={type} variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-semibold">
|
||||
Completion Document
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{service.requiresCompletionDocument
|
||||
? service.completionDocTemplate?.name || "No template linked yet"
|
||||
: "Not required"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
|
||||
{canManage && (
|
||||
<Button variant="outlined" onClick={() => onEdit(service)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{canDeactivate && service.isActive && (
|
||||
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
type EditorProps = {
|
||||
mode: "add" | "edit" | null;
|
||||
form: ServiceForm;
|
||||
formError: string;
|
||||
trades: string[];
|
||||
templates: CompletionDocTemplateOption[];
|
||||
canChangeActiveState: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
|
||||
onToggleType: (type: ServiceWorkOrderType) => void;
|
||||
};
|
||||
|
||||
export function ServiceEditorDrawer({
|
||||
mode,
|
||||
form,
|
||||
formError,
|
||||
trades,
|
||||
templates,
|
||||
canChangeActiveState,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpdate,
|
||||
onToggleType,
|
||||
}: EditorProps) {
|
||||
return (
|
||||
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
|
||||
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
|
||||
<Box className="p-5">
|
||||
<Typography variant="h6" className="font-display font-bold">
|
||||
{mode === "add" ? "Add Service" : "Edit Service"}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
Register a service and whether it needs a completion document.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box className="flex-1 overflow-y-auto p-5">
|
||||
<Stack spacing={2.5}>
|
||||
{Boolean(formError) && <AlertMessage message={formError} />}
|
||||
<ServiceEditorFields
|
||||
mode={mode}
|
||||
form={form}
|
||||
trades={trades}
|
||||
templates={templates}
|
||||
canChangeActiveState={canChangeActiveState}
|
||||
onUpdate={onUpdate}
|
||||
onToggleType={onToggleType}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
type EditorFieldsProps = Pick<
|
||||
EditorProps,
|
||||
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
|
||||
>;
|
||||
|
||||
function ServiceEditorFields({
|
||||
mode,
|
||||
form,
|
||||
trades,
|
||||
templates,
|
||||
canChangeActiveState,
|
||||
onUpdate,
|
||||
onToggleType,
|
||||
}: EditorFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="subtitle2" className="font-semibold">
|
||||
Service Information
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Service Name"
|
||||
value={form.name}
|
||||
required
|
||||
fullWidth
|
||||
onChange={(event) => onUpdate("name", event.target.value)}
|
||||
placeholder="e.g., HVAC Inspection"
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel id="service-trade-label">Trade</InputLabel>
|
||||
<Select
|
||||
labelId="service-trade-label"
|
||||
label="Trade"
|
||||
value={form.trade}
|
||||
onChange={(event) => onUpdate("trade", event.target.value)}
|
||||
>
|
||||
{trades.map((trade) => (
|
||||
<MenuItem key={trade} value={trade}>
|
||||
{trade}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel id="service-icon-label">Icon</InputLabel>
|
||||
<Select
|
||||
labelId="service-icon-label"
|
||||
label="Icon"
|
||||
value={form.iconKey}
|
||||
onChange={(event) => onUpdate("iconKey", event.target.value)}
|
||||
renderValue={(value) => {
|
||||
const icon = ICONS.find((item) => item.key === value);
|
||||
return icon ? (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<icon.Icon size={18} />
|
||||
<span>{icon.label}</span>
|
||||
</Stack>
|
||||
) : (
|
||||
"Pick an icon"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{ICONS.map(({ key, label, Icon }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography variant="subtitle2" className="font-semibold">
|
||||
Supported Work Order Types
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||
{SERVICE_WORK_ORDER_TYPES.map((type) => (
|
||||
<FormControlLabel
|
||||
key={type}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={form.supportedWorkOrderTypes.includes(type)}
|
||||
onChange={() => onToggleType(type)}
|
||||
/>
|
||||
}
|
||||
label={type}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{mode === "edit" && canChangeActiveState && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.isActive ?? true}
|
||||
onChange={(event) => onUpdate("isActive", event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" className="font-semibold">
|
||||
Completion Document
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.requiresCompletionDocument}
|
||||
onChange={(event) => {
|
||||
onUpdate("requiresCompletionDocument", event.target.checked);
|
||||
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Requires completion document"
|
||||
/>
|
||||
{form.requiresCompletionDocument && (
|
||||
<Autocomplete
|
||||
options={templates}
|
||||
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
|
||||
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
|
||||
getOptionLabel={(template) => template.name}
|
||||
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Linked Template" placeholder="Select a template" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Manage the document's own content on the Completion Document Templates page.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{message}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type DeactivateProps = {
|
||||
service: Service | null;
|
||||
open: boolean;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function DeactivateServiceDialog({
|
||||
service,
|
||||
open,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeactivateProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Deactivate this service?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{service?.name} will no longer be selectable for new work orders. Existing work orders
|
||||
will keep showing this service.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
|
||||
Deactivate
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { SettingsNav } from "@/components/common/settings-nav";
|
||||
import {
|
||||
useServicesRegistryController,
|
||||
type ServiceStatus,
|
||||
} from "@/domain/services/use-cases/use-services-registry-controller";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import {
|
||||
DeactivateServiceDialog,
|
||||
ServiceDetailsDrawer,
|
||||
ServiceEditorDrawer,
|
||||
ServiceIcon,
|
||||
} from "@/app/(protected)/settings/_components/services-registry-components";
|
||||
|
||||
function statusTabLabel(label: string, count: number) {
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box component="span">{label}</Box>
|
||||
<Box
|
||||
component="span"
|
||||
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
|
||||
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
|
||||
>
|
||||
{count}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesRegistryView() {
|
||||
const controller = useServicesRegistryController();
|
||||
|
||||
return (
|
||||
<Box className="p-4 md:p-8">
|
||||
<SettingsNav />
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
className="mb-5"
|
||||
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" className="font-display font-extrabold">
|
||||
Services
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
|
||||
{controller.activeCount} active
|
||||
</Typography>
|
||||
</Box>
|
||||
{controller.canManage && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={controller.openCreate}
|
||||
startIcon={<Plus size={16} />}
|
||||
>
|
||||
Add Service
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
|
||||
<TextField
|
||||
size="small"
|
||||
value={controller.search}
|
||||
onChange={(event) => controller.setSearch(event.target.value)}
|
||||
placeholder="Search service or trade..."
|
||||
className="flex-1"
|
||||
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Tabs
|
||||
value={controller.status}
|
||||
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
|
||||
aria-label="Service status"
|
||||
className="mb-4"
|
||||
sx={{
|
||||
minHeight: 36,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTab-root": {
|
||||
minHeight: 36,
|
||||
px: 2,
|
||||
py: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textTransform: "none",
|
||||
},
|
||||
"& .Mui-selected": { fontWeight: 600 },
|
||||
"& .MuiTabs-indicator": { height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value="active"
|
||||
label={statusTabLabel("Active", controller.activeCount)}
|
||||
aria-label={`Active (${controller.activeCount})`}
|
||||
/>
|
||||
<Tab
|
||||
value="inactive"
|
||||
label={statusTabLabel("Inactive", controller.inactiveCount)}
|
||||
aria-label={`Inactive (${controller.inactiveCount})`}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{Boolean(controller.error) && (
|
||||
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
|
||||
)}
|
||||
{controller.isLoading ? (
|
||||
<Box className="flex justify-center py-16">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : controller.filteredServices.length === 0 ? (
|
||||
<EmptyServices
|
||||
search={Boolean(controller.search)}
|
||||
status={controller.status}
|
||||
onAdd={controller.canManage ? controller.openCreate : undefined}
|
||||
/>
|
||||
) : (
|
||||
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
|
||||
)}
|
||||
|
||||
{!controller.isLoading && (
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mt-2"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{controller.filteredServices.length} {controller.status} service
|
||||
{controller.filteredServices.length === 1 ? "" : "s"}
|
||||
{controller.search.trim() ? " · filtered" : ""}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{controller.activeCount} active · {controller.inactiveCount} inactive
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ServiceDetailsDrawer
|
||||
service={controller.selected}
|
||||
open={controller.selected !== null && controller.editorMode === null}
|
||||
canManage={controller.canManage}
|
||||
canDeactivate={controller.canDeactivate}
|
||||
onClose={() => controller.setSelected(null)}
|
||||
onEdit={controller.openEdit}
|
||||
onDeactivate={controller.setDeactivateTarget}
|
||||
/>
|
||||
<ServiceEditorDrawer
|
||||
mode={controller.editorMode}
|
||||
form={controller.form}
|
||||
formError={controller.formError}
|
||||
trades={controller.trades}
|
||||
templates={controller.templates}
|
||||
canChangeActiveState={controller.canDeactivate}
|
||||
isSaving={controller.isSaving}
|
||||
onClose={controller.closeEditor}
|
||||
onSave={controller.save}
|
||||
onUpdate={controller.updateForm}
|
||||
onToggleType={controller.toggleType}
|
||||
/>
|
||||
<DeactivateServiceDialog
|
||||
service={controller.deactivateTarget}
|
||||
open={controller.deactivateTarget !== null}
|
||||
isPending={controller.isDeactivating}
|
||||
onClose={() => controller.setDeactivateTarget(null)}
|
||||
onConfirm={controller.confirmDeactivate}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTable({
|
||||
services,
|
||||
onSelect,
|
||||
}: {
|
||||
services: Service[];
|
||||
onSelect: (service: Service) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
|
||||
<Table aria-label="Services">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Service</TableCell>
|
||||
<TableCell>Trade</TableCell>
|
||||
<TableCell>Completion Document</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{services.map((service) => (
|
||||
<TableRow
|
||||
hover
|
||||
key={String(service.id)}
|
||||
onClick={() => onSelect(service)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<ServiceIcon iconKey={service.iconKey} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{service.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{service.supportedWorkOrderTypes.join(" · ")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{service.trade}</TableCell>
|
||||
<TableCell>
|
||||
{service.requiresCompletionDocument
|
||||
? service.completionDocTemplate?.name || "No template linked yet"
|
||||
: "Not required"}
|
||||
</TableCell>
|
||||
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
|
||||
<TableCell align="right">›</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyServices({
|
||||
search,
|
||||
status,
|
||||
onAdd,
|
||||
}: {
|
||||
search: boolean;
|
||||
status: ServiceStatus;
|
||||
onAdd?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
|
||||
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
|
||||
<Search size={22} aria-hidden="true" />
|
||||
</Box>
|
||||
<Typography variant="body1" className="font-semibold">
|
||||
{search ? "No services found" : `No ${status} services configured yet`}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
{search
|
||||
? "Try a different search, or add a new service."
|
||||
: `There are no ${status} services matching this view.`}
|
||||
</Typography>
|
||||
{onAdd !== undefined && (
|
||||
<Button variant="outlined" className="mt-4" onClick={onAdd}>
|
||||
Add Service
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
5
src/app/(protected)/settings/services.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
|
||||
|
||||
export default function ServicesPage() {
|
||||
return <ServicesRegistryView />;
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -2,12 +2,27 @@ import { toast } from "react-toastify";
|
|||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
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 {
|
||||
void upliftsApi
|
||||
.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) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unable to download evidence right now.",
|
||||
);
|
||||
tab?.close();
|
||||
reportEvidenceError(error);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -7,21 +8,30 @@ import {
|
|||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} 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 { 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 { 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 { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
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 UNAVAILABLE_LABEL = "Unavailable";
|
||||
const BORDERED_BLOCK_SX = {
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
px: 1.75,
|
||||
py: 1.25,
|
||||
} as const;
|
||||
|
||||
type ModalCallbacks = {
|
||||
onClose: () => void;
|
||||
|
|
@ -31,24 +41,122 @@ type ModalCallbacks = {
|
|||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
};
|
||||
|
||||
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
|
||||
|
||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="label" tone="muted" className="block">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
<Text variant="body">{children}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
function SubsectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="heading" className="mb-1 block">
|
||||
{title}
|
||||
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
|
||||
{children}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,142 +175,61 @@ function UpliftAttachments({
|
|||
</Text>
|
||||
);
|
||||
}
|
||||
const canOpen = item.evidenceDocumentId != null;
|
||||
const extraCount =
|
||||
typeof item.attachmentCount === "number" && item.attachmentCount > 1
|
||||
? item.attachmentCount - 1
|
||||
: 0;
|
||||
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
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={item.evidenceFileName || "Attachment"}
|
||||
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
|
||||
clickable={item.evidenceDocumentId != null}
|
||||
title={canOpen ? "Opens in a new tab" : undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkOrderSection({
|
||||
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({
|
||||
function UpliftRequestSection({
|
||||
item,
|
||||
breakdown,
|
||||
breakdownUnavailable,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
breakdown: ExposureBreakdown;
|
||||
breakdownUnavailable: boolean;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const waitingClass = item.requestedAt
|
||||
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
|
||||
: undefined;
|
||||
return (
|
||||
<DetailSection title="Request">
|
||||
<DetailField label="Amount">
|
||||
<Text variant="body">
|
||||
{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 : "—"}
|
||||
<Box component="section" aria-label="Uplift request">
|
||||
<Text variant="heading" className="mb-2 block">
|
||||
Uplift request
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -223,6 +250,19 @@ function PendingModalActions({
|
|||
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onReject(item)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
|
|
@ -236,73 +276,61 @@ function PendingModalActions({
|
|||
</Button>
|
||||
</span>
|
||||
</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,
|
||||
closed,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
closed: boolean;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const closed = item.workOrderClosed === true;
|
||||
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
||||
return (
|
||||
<>
|
||||
{item.status === "Pending" && (
|
||||
<PendingModalActions
|
||||
item={item}
|
||||
approvePending={approvePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
)}
|
||||
{item.status === "Approved" && (
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||
onClick={() => onRevoke(item)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
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,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
|
|
@ -312,98 +340,102 @@ export function UpliftDetailModal({
|
|||
onReject,
|
||||
onRevoke,
|
||||
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;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
} & ModalCallbacks) {
|
||||
const open = 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) {
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
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 open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
|
||||
<UpliftDetailContent item={item} {...rest} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||
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 { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
|
|||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
onOpenAttachment={openUpliftEvidenceInNewTab}
|
||||
/>
|
||||
)}
|
||||
<UpliftDecisionDialogs controller={controller} />
|
||||
|
|
|
|||
|
|
@ -97,12 +97,13 @@ export function CompDocDialog({
|
|||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||
|
||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
||||
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
|
||||
// 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({
|
||||
workOrderId: row.id,
|
||||
rowVersion,
|
||||
onUploaded: dialog.handleGenerate,
|
||||
onUploaded: dialog.markSignedPdfUploaded,
|
||||
});
|
||||
|
||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
|
|
@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean {
|
|||
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
|
||||
}
|
||||
|
||||
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
type UseSlideOverEditStateArgs = {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
|
|
@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = {
|
|||
onClose: () => void;
|
||||
closeDisabled: boolean;
|
||||
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,
|
||||
editMode,
|
||||
infoSource,
|
||||
onSave,
|
||||
onClose,
|
||||
closeDisabled,
|
||||
initialTab,
|
||||
setTab,
|
||||
}: 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.
|
||||
setDraft,
|
||||
setBaseline,
|
||||
setEditing,
|
||||
setShowUnsaved,
|
||||
setUnsavedIntent,
|
||||
}: {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
initialTab: SlideOverTab;
|
||||
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(() => {
|
||||
if (!row) {
|
||||
setDraft(null);
|
||||
|
|
@ -59,12 +69,50 @@ export function useSlideOverEditState({
|
|||
setDraft(row);
|
||||
setBaseline(row);
|
||||
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
||||
setTab("info");
|
||||
setTab(editMode ? "info" : initialTab);
|
||||
setShowUnsaved(false);
|
||||
setUnsavedIntent(null);
|
||||
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||
}, [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 isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||
|
|
@ -95,8 +143,7 @@ export function useSlideOverEditState({
|
|||
});
|
||||
}, [infoSource, editing, baseline]);
|
||||
|
||||
const activeDraft = draft ?? infoSource;
|
||||
const dirtyBaseline = baseline ?? infoSource;
|
||||
const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
|
||||
const isDirty = useMemo(() => {
|
||||
if (!dirtyBaseline || !activeDraft || !editing) return false;
|
||||
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
isWorkOrderFullyLocked,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
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 { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||
|
|
@ -35,24 +34,21 @@ type UseWorkOrderSlideOverArgs = {
|
|||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
initialTab?: SlideOverTab;
|
||||
openRequestKey?: number;
|
||||
};
|
||||
|
||||
type CompletionUploadMutate = (
|
||||
input: { file: File; workOrderVersion: string },
|
||||
options: { onSuccess: () => Promise<void> | void },
|
||||
) => void;
|
||||
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
|
||||
|
||||
function uploadSlideOverCompletionPdf({
|
||||
file,
|
||||
row,
|
||||
infoSource,
|
||||
onSave,
|
||||
mutate,
|
||||
}: {
|
||||
file: File;
|
||||
row: WorkOrderTableRow | null;
|
||||
infoSource: WorkOrderTableRow | null | undefined;
|
||||
onSave: UseWorkOrderSlideOverArgs["onSave"];
|
||||
mutate: CompletionUploadMutate;
|
||||
}) {
|
||||
const version = infoSource?.rowVersion;
|
||||
|
|
@ -60,27 +56,11 @@ function uploadSlideOverCompletionPdf({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
mutate(
|
||||
{ file, workOrderVersion: version },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||
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.");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
// The upload transitions the completion document to Yes and bumps the work-order row
|
||||
// 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
|
||||
// successful upload, so it is intentionally omitted.
|
||||
mutate({ file, workOrderVersion: version });
|
||||
}
|
||||
|
||||
function resolveSlideOverSource(
|
||||
|
|
@ -112,6 +92,8 @@ export function useWorkOrderSlideOver({
|
|||
onSave,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
}: UseWorkOrderSlideOverArgs) {
|
||||
const { user } = useAuthContext();
|
||||
const workOrderId = row?.id;
|
||||
|
|
@ -160,6 +142,8 @@ export function useWorkOrderSlideOver({
|
|||
onClose,
|
||||
closeDisabled,
|
||||
setTab,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
});
|
||||
|
||||
const uploadCompletionPdf = (file: File) => {
|
||||
|
|
@ -167,7 +151,6 @@ export function useWorkOrderSlideOver({
|
|||
file,
|
||||
row,
|
||||
infoSource,
|
||||
onSave,
|
||||
mutate: uploadCompletionMutation.mutate,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} 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 { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||
|
||||
|
|
@ -35,6 +35,8 @@ type WorkOrderSlideOverProps = {
|
|||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
initialTab?: SlideOverTab;
|
||||
openRequestKey?: number;
|
||||
};
|
||||
|
||||
export function WorkOrderSlideOver({
|
||||
|
|
@ -52,8 +54,18 @@ export function WorkOrderSlideOver({
|
|||
onDuplicateFound,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
}: WorkOrderSlideOverProps) {
|
||||
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
|
||||
const state = useWorkOrderSlideOver({
|
||||
row,
|
||||
editMode,
|
||||
onSave,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
});
|
||||
const { infoSource, activeDraft } = state;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
|
|
@ -37,7 +37,7 @@ export function useVendorDialogForm({
|
|||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const addTechnicians = useAddTechniciansToCompany();
|
||||
const [selectedCompany, setSelectedCompany] = useState(company);
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
|
|
@ -119,7 +119,7 @@ export function useVendorDialogForm({
|
|||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -141,7 +141,7 @@ export function useVendorDialogForm({
|
|||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
isPending: addTechnicians.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
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 { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
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 = {
|
||||
allVendors: VendorDropdownItem[];
|
||||
|
|
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
|
|||
anchorCompanyName: string;
|
||||
tech: string;
|
||||
phone: string;
|
||||
createVendor: ReturnType<typeof useCreateVendor>;
|
||||
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
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({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
|
|
@ -30,7 +61,7 @@ export function useVendorDialogSave({
|
|||
anchorCompanyName,
|
||||
tech,
|
||||
phone,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -53,11 +84,12 @@ export function useVendorDialogSave({
|
|||
|
||||
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
|
||||
try {
|
||||
const created = await createVendor.mutateAsync({
|
||||
...pendingPayload,
|
||||
phone: phone.trim() || pendingPayload.phone,
|
||||
saveVendor = await persistDraftTechnician({
|
||||
allVendors,
|
||||
pendingPayload,
|
||||
phone,
|
||||
addTechnicians,
|
||||
});
|
||||
saveVendor = vendorToDropdownItem(created);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create technician";
|
||||
setSaveError(message);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
<WorkOrderSlideOver
|
||||
row={page.activeSlideOverRow}
|
||||
editMode={page.slideOverEdit}
|
||||
initialTab={page.slideOverTab}
|
||||
openRequestKey={page.slideOverOpenRequestKey}
|
||||
users={users}
|
||||
sites={locations}
|
||||
vendors={vendors}
|
||||
|
|
|
|||
|
|
@ -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 { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
|
||||
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 { 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
|
||||
// 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
|
||||
|
|
@ -72,6 +73,11 @@ export function useNewWoWizardController({
|
|||
onOpenChange(false);
|
||||
void saveCompanyNotes(createdDraft);
|
||||
});
|
||||
const createMutation = useWizardCreateWithTechnicians({
|
||||
vendors,
|
||||
patchDraft: draftState.patchDraft,
|
||||
createMutation: createWorkOrder,
|
||||
});
|
||||
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
|
||||
useWizardDuplicateActions({
|
||||
open,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
|
|||
onPatch,
|
||||
}: WizardStepLocationServiceSelectProps) {
|
||||
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 selected = services.find((service) => service.value === p);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
|
|
@ -8,10 +9,7 @@ import {
|
|||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import {
|
||||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
|
|
@ -30,7 +28,6 @@ import {
|
|||
ColorDot,
|
||||
Detail,
|
||||
WizLabel,
|
||||
WizLabelRow,
|
||||
WizSubLabel,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
|
|
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
|
|||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.vendorId !== ""
|
||||
? () => 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>
|
||||
<WizardVendorCompanyFields
|
||||
draft={draft}
|
||||
vendors={vendors}
|
||||
selectedVendor={selectedVendor}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
{draft.vendorId !== "" && selectedVendor != null && (
|
||||
<WizardVendorDetail
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,11 +162,22 @@ export function createCompDocDialogActions({
|
|||
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 {
|
||||
handleMarkNotRequired,
|
||||
handleUndo,
|
||||
handleSaveDraft,
|
||||
handleGenerate,
|
||||
handleDownloadPdf,
|
||||
markSignedPdfUploaded,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,19 @@ export function useDashboardDrilldownFilters(
|
|||
}, [onApply]);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drilldown = parseDashboardDrilldownParams(searchParams);
|
||||
if (!drilldown) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedRef.current = true;
|
||||
setFromDashboard(true);
|
||||
setFromDashboard(searchParams.get("fromDashboard") === "1");
|
||||
onApplyRef.current(drilldown);
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
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 { 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 {
|
||||
useWorkOrderLocations,
|
||||
|
|
@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard";
|
|||
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
||||
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
||||
|
||||
export function useWorkOrdersListPage() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
function useSlideOverOpenState() {
|
||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||
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 [confirmComplete, setConfirmComplete] = 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;
|
||||
}, [upliftsRow, tableData.rows]);
|
||||
|
||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||
setSlideOverRow(row);
|
||||
setSlideOverEdit(edit);
|
||||
};
|
||||
|
||||
const handleCloseSlideOver = () => {
|
||||
setSlideOverRow(null);
|
||||
setSlideOverEdit(false);
|
||||
|
|
@ -130,6 +161,8 @@ export function useWorkOrdersListPage() {
|
|||
wizardOpen,
|
||||
setWizardOpen,
|
||||
slideOverEdit,
|
||||
slideOverTab,
|
||||
slideOverOpenRequestKey,
|
||||
confirmCancel,
|
||||
setConfirmCancel,
|
||||
confirmComplete,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
|
|||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
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 { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
|
|||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData } = page;
|
||||
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||
useWorkOrderDeepLink(page.handleOpenSlideOver);
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NavLink } from "react-router";
|
|||
import { Stack } from "@mui/material";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ path: "/settings/services", label: "Services" },
|
||||
{ path: "/settings/dropdowns", label: "Dropdown Options" },
|
||||
{ path: "/settings/task-templates", label: "Task Templates" },
|
||||
] as const;
|
||||
|
|
|
|||
189
src/components/layout/app-topbar-user-menu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
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 { AppBar, Box, Toolbar } from "@mui/material";
|
||||
import { Menu as MenuIcon } from "lucide-react";
|
||||
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
|
||||
import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
|
||||
type AppTopbarProps = {
|
||||
onToggleNav: () => void;
|
||||
};
|
||||
|
||||
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 (
|
||||
<AppBar
|
||||
position="sticky"
|
||||
|
|
@ -97,135 +62,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
|||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<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>
|
||||
</Toolbar>
|
||||
<NotificationBell />
|
||||
|
||||
<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)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<AppTopbarUserMenu />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
src/components/notifications/notification-bell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
src/components/notifications/notification-feed-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
src/components/notifications/notification-feed-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/notifications/notification-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/notifications/use-notification-center.ts
Normal 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>;
|
||||
|
|
@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
|
|||
(status) => status !== "Completed",
|
||||
);
|
||||
|
||||
export function workOrderDrilldownUrl(search: URLSearchParams): string {
|
||||
const query = search.toString();
|
||||
export function workOrderDrilldownUrl(
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/domain/notifications/api/notifications-api.ts
Normal 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")),
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
62
src/domain/notifications/types/notification-feed.ts
Normal 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>;
|
||||
16
src/domain/notifications/use-cases/use-notification-feed.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
46
src/domain/notifications/utils/notification-target-url.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapServices } from "@/domain/services/mappers/service-mapper";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import {
|
||||
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 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> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
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 = {
|
||||
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[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true },
|
||||
|
|
@ -27,4 +57,23 @@ export const servicesApi = {
|
|||
});
|
||||
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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,62 +1,77 @@
|
|||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
CompletionDocTemplateOption,
|
||||
LinkedServiceTemplate,
|
||||
Service,
|
||||
ServiceWorkOrderType,
|
||||
} from "@/domain/services/types/service";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readId(item: Record<string, unknown>): string | number {
|
||||
const value = item.id ?? item.Id;
|
||||
return typeof value === "string" || typeof value === "number" ? value : "";
|
||||
}
|
||||
|
||||
function readString(item: Record<string, unknown>, ...keys: string[]): string {
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number") return String(value);
|
||||
}
|
||||
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) {
|
||||
const value = item[key];
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapWorkOrderType(value: unknown): WOType | null {
|
||||
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
|
||||
if (typeof value === "number") {
|
||||
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "pm" || normalized === "preventivemaintenance"
|
||||
? "PM"
|
||||
: normalized === "emergency"
|
||||
? "Emergency"
|
||||
: normalized === "reactive"
|
||||
? "Reactive"
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "pm" || normalized === "preventivemaintenance"
|
||||
? "PM"
|
||||
: normalized === "emergency"
|
||||
? "Emergency"
|
||||
: normalized === "reactive"
|
||||
? "Reactive"
|
||||
: 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 {
|
||||
const item = asRecord(raw);
|
||||
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
|
||||
const record = asRecord(raw);
|
||||
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
|
||||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name"),
|
||||
trade: readString(item, "trade", "Trade"),
|
||||
iconKey: readString(item, "iconKey", "IconKey"),
|
||||
id: readId(record),
|
||||
name: readString(record, "name", "Name"),
|
||||
trade: readString(record, "trade", "Trade"),
|
||||
iconKey: readString(record, "iconKey", "IconKey"),
|
||||
requiresCompletionDocument: readBool(
|
||||
item,
|
||||
record,
|
||||
"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)
|
||||
? types.flatMap((value) => {
|
||||
const mapped = mapWorkOrderType(value);
|
||||
|
|
@ -67,5 +82,34 @@ export function mapService(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,
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id: string | number;
|
||||
|
|
@ -6,6 +15,17 @@ export interface Service {
|
|||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplate: LinkedServiceTemplate | null;
|
||||
isActive: boolean;
|
||||
supportedWorkOrderTypes: WOType[];
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
}
|
||||
|
||||
export interface ServiceInput {
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplateId: number | null;
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
62
src/domain/services/use-cases/use-services.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
|
|
@ -106,28 +106,61 @@ export const upliftsApi = {
|
|||
id: string | number,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
|
||||
const blob = await response.blob();
|
||||
const fileName = readContentDispositionFilename(response, fallbackFileName);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the evidence in `tab`, which the caller opens synchronously on click so
|
||||
* popup blockers allow it. Only inert types render inline on the app origin; any
|
||||
* 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
"trade",
|
||||
"Trade",
|
||||
),
|
||||
technicianName: readString(item, "technicianName", "TechnicianName"),
|
||||
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
|
||||
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
|
||||
attachmentCount: readNumber(
|
||||
item,
|
||||
"attachmentCount",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
|
|||
woNumber: string;
|
||||
site: string;
|
||||
serviceName: string;
|
||||
technicianName: string;
|
||||
workOrderDispatcherName: string;
|
||||
workOrderScheduledDate: string;
|
||||
attachmentCount: number | null;
|
||||
approvedOnWoAuto: number | null;
|
||||
approvedOnWoAdmin: number | null;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
VendorAvailability,
|
||||
VendorAssignmentPayload,
|
||||
VendorInsight,
|
||||
VendorNotification,
|
||||
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||
|
||||
const base = "vendor-operations";
|
||||
|
|
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
|
|||
}
|
||||
|
||||
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: {
|
||||
start?: string;
|
||||
end?: string;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id: number;
|
||||
companyName: string;
|
||||
|
|
|
|||
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -216,10 +216,14 @@ export const workOrderBoardDocumentsApi = {
|
|||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): 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(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
{ throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
|
|
|
|||
81
src/domain/work-orders/api/work-order-board-patch-api.ts
Normal 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),
|
||||
};
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
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 { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
|
||||
import {
|
||||
mapDispatchComment,
|
||||
mapDispatchDetail,
|
||||
|
|
@ -20,7 +16,6 @@ import {
|
|||
mapWorkOrderDetail,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
mapWorkOrderTableRow,
|
||||
mapWorkOrderTableRows,
|
||||
} from "@/domain/work-orders/mappers/work-order-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 {
|
||||
WorkOrderBoardListResult,
|
||||
WorkOrderBoardPatchPayload,
|
||||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardResponseApi,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
|
|
@ -310,51 +304,10 @@ export const workOrdersApi = {
|
|||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
patchBoardField: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
patchBoardField: workOrderBoardPatchApi.patchBoardField,
|
||||
|
||||
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);
|
||||
},
|
||||
/** Manual POC override — same row/error contract as the board patch. */
|
||||
updatePoc: workOrderBoardPatchApi.updatePoc,
|
||||
|
||||
update: async (
|
||||
id: string | number,
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
"techPhone",
|
||||
"location",
|
||||
"locationId",
|
||||
"pocName",
|
||||
"pocPhone",
|
||||
"pocNotes",
|
||||
"title",
|
||||
"priority",
|
||||
"apptStart",
|
||||
|
|
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
|||
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).
|
||||
* Schedule/week-only fields are emitted first in a stable order.
|
||||
* Expands a UI table patch into ordered board ops (one request each).
|
||||
* 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)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
|
||||
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
|
||||
}
|
||||
|
||||
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
|
|||
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 =
|
||||
| "woNumber"
|
||||
| "workOrderType"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
|
|||
|
||||
export type WizardSchedMode = "date" | "week" | "range";
|
||||
|
||||
export interface WizardNewTechnician {
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderWizardDraft {
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
|
|
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
|
|||
avetaRequired: boolean;
|
||||
vendorId: 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;
|
||||
vendorNotes: string;
|
||||
apptStart: string;
|
||||
|
|
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
avetaRequired: false,
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
vendorCompany: "",
|
||||
newTechnicians: [],
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
apptStart: "",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
buildBoardPatchPayload,
|
||||
expandTablePatchToBoardFieldOps,
|
||||
isLocalOnlyTablePatch,
|
||||
type BoardOp,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type {
|
||||
|
|
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
|
|||
type PatchResult =
|
||||
{ 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(
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
|
|
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
|
|||
let completedOps = 0;
|
||||
for (const op of ops) {
|
||||
try {
|
||||
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||
latest = await applyBoardOp(id, op, latest);
|
||||
completedOps += 1;
|
||||
} catch (error) {
|
||||
if (completedOps > 0) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import type {
|
|||
/**
|
||||
* Fields compared for dirty state and included in Save patch.
|
||||
* 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).
|
||||
*/
|
||||
export const SLIDE_OVER_EDIT_KEYS = [
|
||||
|
|
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"vendorId",
|
||||
// company/tech are display companions; board persists vendor via vendorId only
|
||||
"status",
|
||||
"pocName",
|
||||
"pocPhone",
|
||||
"pocNotes",
|
||||
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
||||
|
||||
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];
|
||||
|
|
|
|||
48
src/domain/work-orders/utils/work-order-deep-link.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -75,6 +75,8 @@ export const queryKeys = {
|
|||
},
|
||||
services: {
|
||||
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,
|
||||
byWorkOrderType: (workOrderType: string) =>
|
||||
[...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,
|
||||
sites: () => [...queryKeys.assets.all, "sites"] as const,
|
||||
},
|
||||
notifications: {
|
||||
all: ["notifications"] as const,
|
||||
feed: () => [...queryKeys.notifications.all, "feed"] as const,
|
||||
},
|
||||
vendorPortal: {
|
||||
all: ["vendorPortal"] as const,
|
||||
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,
|
||||
|
|
|
|||