mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
chore: integrate current dev into board foundation
This commit is contained in:
commit
ca9d9e9c2a
187 changed files with 10764 additions and 5247 deletions
27
.github/workflows/ci.yaml
vendored
27
.github/workflows/ci.yaml
vendored
|
|
@ -11,6 +11,33 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
|
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
|
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). If the reusable workflow is later confirmed to run
|
||||||
|
# every gate, this job can be slimmed to `npm run governance`.
|
||||||
|
#
|
||||||
|
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||||
|
# PR -> the PR target branch (origin/<base_ref>)
|
||||||
|
# push-> the previous commit on the branch (github.event.before)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run verify
|
||||||
|
|
|
||||||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# AGENTS.md — frontend mandatory conventions
|
||||||
|
|
||||||
|
This file is the binding entry point for **every contributor — human or AI coding
|
||||||
|
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
|
||||||
|
frontend). It makes the validated React/TypeScript conventions mandatory and
|
||||||
|
points to the operational documents and executable gates that enforce them.
|
||||||
|
|
||||||
|
Read these before writing or reviewing code. They override generic "best
|
||||||
|
practice" suggestions from any agent or model. This repository contract may
|
||||||
|
strengthen, but never weaken, the workspace-level `AGENTS.md`.
|
||||||
|
|
||||||
|
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
|
||||||
|
command, and the no-false-pass guarantees.
|
||||||
|
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
|
||||||
|
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
|
||||||
|
data access, security, maintainability) with evidence and thresholds.
|
||||||
|
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
|
||||||
|
contract (exact-head review, board regression inventory, behavior-based
|
||||||
|
testing, security/performance/Big-O review, no style-only comments).
|
||||||
|
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
|
||||||
|
detailed rationale for the conditional-rendering and typography rules.
|
||||||
|
|
||||||
|
## One command to run every gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run verify
|
||||||
|
```
|
||||||
|
|
||||||
|
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||||
|
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||||
|
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||||
|
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||||
|
`governance` job, so a green local run mirrors CI.
|
||||||
|
|
||||||
|
## Non-negotiable rules (enforced; do not work around)
|
||||||
|
|
||||||
|
These are already enforced by lint/build or the governance script. Disabling,
|
||||||
|
baselining, or per-line-suppressing them is forbidden (see exceptions below).
|
||||||
|
|
||||||
|
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
|
||||||
|
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
|
||||||
|
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
|
||||||
|
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
|
||||||
|
the `vp-error` class outside `Text` are rejected.
|
||||||
|
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
|
||||||
|
do not silence it.
|
||||||
|
- **Hooks correctness** — the `react-hooks` recommended rules (including
|
||||||
|
`exhaustive-deps`) run under the zero-warnings gate.
|
||||||
|
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
|
||||||
|
has a frozen per-file cap that may only decrease.
|
||||||
|
- **Changed-file maintainability** — changed TS/TSX must meet
|
||||||
|
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
|
||||||
|
|
||||||
|
## How to add / change a convention
|
||||||
|
|
||||||
|
1. Land it **green**: a new or tightened rule must ship with the codebase passing
|
||||||
|
it (a migration in the same change), not as a warning-only backlog.
|
||||||
|
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
|
||||||
|
new baseline debt or raise a frozen cap.
|
||||||
|
3. Document the rule, its threshold, and its evidence in
|
||||||
|
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||||
|
|
||||||
|
## Exceptions and the ADR process
|
||||||
|
|
||||||
|
Exceptions are **not granted by disabling a rule inline**. To deviate:
|
||||||
|
|
||||||
|
1. Record an **Architecture Decision Record** under `docs/adr/`
|
||||||
|
(`NNNN-title.md`: context, decision, consequences, alternatives).
|
||||||
|
2. Existing grandfathered entries may only be removed or have their caps
|
||||||
|
reduced. New entries and cap increases fail the governance gate.
|
||||||
|
3. Get it reviewed like any other change. The ADR + baseline entry is the
|
||||||
|
auditable record; an `eslint-disable` comment is not.
|
||||||
|
|
||||||
|
## Toolchain (do not change without an ADR)
|
||||||
|
|
||||||
|
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
|
||||||
|
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
|
||||||
|
`packageManager`, invoked through corepack). **No new dependencies without an
|
||||||
|
ADR** — prefer the libraries already established (see
|
||||||
|
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
|
||||||
|
data access).
|
||||||
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
|
||||||
|
|
||||||
|
Mandatory React/TypeScript conventions for this repository. Each rule lists how
|
||||||
|
it is enforced. Detailed rationale for rendering and typography lives in
|
||||||
|
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
|
||||||
|
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
|
||||||
|
means it is a review-enforced convention backed by the
|
||||||
|
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
|
||||||
|
|
||||||
|
## Conditional rendering
|
||||||
|
|
||||||
|
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
|
||||||
|
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
|
||||||
|
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
|
||||||
|
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
|
||||||
|
semantics and TypeScript narrowing with `count > 0`, `value != null`.
|
||||||
|
`{count && <X/>}` renders `0` and is rejected by the type-aware
|
||||||
|
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
|
||||||
|
the approved guard forms.)
|
||||||
|
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
|
||||||
|
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
|
||||||
|
|
||||||
|
## Typography and feedback
|
||||||
|
|
||||||
|
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
|
||||||
|
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
|
||||||
|
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
|
||||||
|
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
|
||||||
|
`vp-error` class on any non-`Text` element is rejected
|
||||||
|
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
|
||||||
|
bypass the static check.
|
||||||
|
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
|
||||||
|
omitted from `TextProps` so the variant contract (element, tone, live region)
|
||||||
|
cannot be overridden. Use `as` and `tone`.
|
||||||
|
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
|
||||||
|
with `when`; mount `error` (assertive `alert`) on demand.
|
||||||
|
|
||||||
|
## Forms and mutations
|
||||||
|
|
||||||
|
- **MUST** use the libraries already established: **React Hook Form** for field
|
||||||
|
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
|
||||||
|
Query** for server reads and mutations (pending/error state, invalidation,
|
||||||
|
retries).
|
||||||
|
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
|
||||||
|
second form convention increases cognitive and dependency cost. Replacing
|
||||||
|
React Hook Form requires an approved repo-wide migration ADR with measured
|
||||||
|
benefits, a codemod/migration plan, and removal of the superseded dependency.
|
||||||
|
- **SHOULD** keep file upload selection/validation in a focused component and
|
||||||
|
drive upload progress/errors/retry through a TanStack Query mutation, not in a
|
||||||
|
route-sized page.
|
||||||
|
|
||||||
|
## State ownership (bounded)
|
||||||
|
|
||||||
|
- **MUST** keep query loading/error/empty state adjacent to the query result;
|
||||||
|
mutation pending/error state in the component that initiated the mutation.
|
||||||
|
- **MUST** compose focused state components instead of accumulating unrelated
|
||||||
|
booleans in a page. Route pages coordinate sections and navigation; reusable
|
||||||
|
sections own their interaction details.
|
||||||
|
- **SHOULD** render errors inline with accessible feedback (`Text
|
||||||
|
variant="error"`); reserve toasts for cross-page outcomes.
|
||||||
|
|
||||||
|
## Data access
|
||||||
|
|
||||||
|
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
|
||||||
|
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
|
||||||
|
break caching and invalidation. (Review-enforced; a future lint rule is
|
||||||
|
tracked as a gap.)
|
||||||
|
- **MUST NOT** materialize large server collections into client state and then
|
||||||
|
filter/sort them in the component when the server (or a memoized, virtualized
|
||||||
|
layer) should own it. Prefer server-side filtering/pagination; if client-side
|
||||||
|
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
|
||||||
|
- **MUST** invalidate the right query keys after a mutation so caches do not
|
||||||
|
show stale data.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **MUST NOT** surface raw server error payloads, stack traces, or internal
|
||||||
|
identifiers to end users. Map server errors to a safe user-facing message
|
||||||
|
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
|
||||||
|
(Review-enforced.)
|
||||||
|
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
|
||||||
|
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
|
||||||
|
never put a secret in a `VITE_` variable.
|
||||||
|
|
||||||
|
## Hooks, performance, and Big-O
|
||||||
|
|
||||||
|
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
|
||||||
|
under the zero-warnings gate.
|
||||||
|
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
|
||||||
|
data, key lists stably, and prefer server-side filtering for large sets
|
||||||
|
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
|
||||||
|
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
|
||||||
|
only when measured to matter (no speculative memoization).
|
||||||
|
|
||||||
|
## Maintainability (measured thresholds)
|
||||||
|
|
||||||
|
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
|
||||||
|
These are conservative code-shape proxies for review focus. File length is not
|
||||||
|
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
|
||||||
|
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
|
||||||
|
judgment.
|
||||||
|
|
||||||
|
| Metric | Threshold | How enforced |
|
||||||
|
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
|
||||||
|
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
|
||||||
|
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
|
||||||
|
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
|
||||||
|
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
|
||||||
|
|
||||||
|
**Why these are changed-file ratchets, not whole-repo errors:** measured against
|
||||||
|
the current codebase, the maintainability thresholds surface **72 violations
|
||||||
|
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
|
||||||
|
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
|
||||||
|
legacy without a migration. Instead:
|
||||||
|
|
||||||
|
- The **5 oversized files** are explicit grandfathered debt with frozen
|
||||||
|
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
|
||||||
|
grow; new entries and cap increases fail.
|
||||||
|
- The **complexity/function-length/params/depth** rules apply to **changed**
|
||||||
|
TS/TSX on a PR/push, so new and modified code must comply while untouched
|
||||||
|
legacy is not blocked. Bring legacy into compliance when you next touch it.
|
||||||
|
|
||||||
|
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
|
||||||
|
entries) as debt is paid down. Never raise them to make a change pass.
|
||||||
54
QUALITY_GATES.md
Normal file
54
QUALITY_GATES.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# QUALITY_GATES.md — executable frontend gates
|
||||||
|
|
||||||
|
The single command that runs **every** gate, locally and in CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||||
|
`test` (`vitest run`) → `governance`. 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 |
|
||||||
|
|
||||||
|
## No-false-pass guarantees
|
||||||
|
|
||||||
|
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
|
||||||
|
backlog; rules ship green (see AGENTS.md → How to add a convention).
|
||||||
|
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
|
||||||
|
when type services are unavailable rather than silently claiming safety.
|
||||||
|
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
|
||||||
|
entry, global cap increase, per-file cap increase, or growth beyond a frozen
|
||||||
|
legacy cap fails. Only cap reductions and entry removals are allowed.
|
||||||
|
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||||
|
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||||
|
(push). An absent or unresolvable base is a failure, not a pass.
|
||||||
|
|
||||||
|
## Where the gates run
|
||||||
|
|
||||||
|
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||||
|
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||||
|
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||||
|
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||||
|
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||||
|
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||||
|
repository regardless of the reusable workflow.
|
||||||
|
|
||||||
|
## Toolchain pin
|
||||||
|
|
||||||
|
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||||
|
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||||
|
`package-lock.json` v3; install with `npm ci`.
|
||||||
238
README.md
238
README.md
|
|
@ -1,80 +1,188 @@
|
||||||
# SeaHaven Admin
|
# SHOC Frontend (`shoc-frontend-new`)
|
||||||
|
|
||||||
Vite + React SPA for SeaHaven facility management (work orders, vendor portal, uplifts, and related admin features).
|
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||||
|
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Requirements
|
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
|
||||||
|
portal, uplifts, and related admin features. This is the selective rebuild of
|
||||||
|
the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
|
||||||
|
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
||||||
|
|
||||||
- Node.js 22.22.1+
|
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||||
- npm 11.16.0
|
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||||
|
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm ci
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
| ----------------- | ----------------------------------------------- | ----------------------- |
|
|
||||||
| `VITE_API_URL` | Ky API prefix (baked into the production build) | `/api` |
|
|
||||||
| `VITE_API_TARGET` | Dev proxy target for `/api` (Vite only) | `http://localhost:5141` |
|
|
||||||
|
|
||||||
`VITE_API_URL` is the full API base prefix. Route paths in `API_PATHS` do **not** include `/api` — only this variable supplies it.
|
|
||||||
|
|
||||||
- **Development:** use `VITE_API_URL=/api`. The Vite dev server proxies `/api` to `VITE_API_TARGET`.
|
|
||||||
- **Production:** use an absolute URL that **ends with `/api`** (e.g. `https://api.example.com/api`). Absolute URLs without `/api` will produce broken API requests.
|
|
||||||
|
|
||||||
See [`.env.example`](.env.example) and [`.env.production`](.env.production) for reference values.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| ---------------------- | ------------------------------------------ |
|
|
||||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
|
||||||
| `npm run build` | Type-check and production build to `dist/` |
|
|
||||||
| `npm run preview` | Preview production build locally |
|
|
||||||
| `npm test` | Run Vitest unit tests |
|
|
||||||
| `npm run test:watch` | Run Vitest in watch mode |
|
|
||||||
| `npm run lint` | ESLint |
|
|
||||||
| `npm run lint:fix` | ESLint with auto-fix |
|
|
||||||
| `npm run format` | Prettier write |
|
|
||||||
| `npm run format:check` | Prettier check (used in CI) |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
New code lives under `src/domain/`, `src/app/`, and `src/api/` following the IrisLoan conventions documented in `docs/ARCHITECTURE_PLAN.md`.
|
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
||||||
|
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
||||||
|
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||||
|
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
|
||||||
|
|
||||||
## CI
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
|
||||||
|
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||||
|
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||||
|
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||||
|
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||||
|
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
||||||
|
```
|
||||||
|
|
||||||
GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push and pull requests:
|
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||||
|
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||||
|
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||||
|
architecture plan for the keep/discard migration matrix).
|
||||||
|
|
||||||
1. `npm run format:check`
|
## AWS Resources
|
||||||
2. `npm run lint`
|
|
||||||
3. `npm run build`
|
|
||||||
4. `npm test`
|
|
||||||
|
|
||||||
Local pre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files.
|
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
||||||
|
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||||
|
|
||||||
## Deployment (CI/CD)
|
| Resource | Name | Purpose |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
|
||||||
|
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
|
||||||
|
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
|
||||||
|
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||||
|
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
|
||||||
|
|
||||||
The app is hosted on **AWS S3 + CloudFront**, provisioned by an **AWS CDK** app
|
No Lambdas, queues, or databases — this stack is static hosting only.
|
||||||
local to this repo ([`infra/cdk/`](infra/cdk/README.md)). Deployment runs
|
|
||||||
through the org's reusable GitHub Actions workflow via **OIDC** (no stored AWS
|
|
||||||
keys):
|
|
||||||
|
|
||||||
- Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable
|
## Configuration
|
||||||
`cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh`
|
|
||||||
(builds the SPA, syncs `dist/` to S3, invalidates CloudFront).
|
|
||||||
- Served on the custom domain `dev.seahaven.com`; the SPA calls the backend
|
|
||||||
directly over HTTPS at `VITE_API_URL` (`https://api.dev.seahaven.com/api`,
|
|
||||||
cross-origin — the backend allows CORS). `VITE_API_URL` is baked into the
|
|
||||||
build, so it is per-environment.
|
|
||||||
- First-time provisioning (OIDC provider, CDK bootstrap, first local deploy, the
|
|
||||||
`AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see
|
|
||||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
|
||||||
|
|
||||||
## Development proxy
|
### Secrets
|
||||||
|
|
||||||
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).
|
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
||||||
|
repo secret**:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
||||||
|
|
||||||
|
### Environment variables (build-time, `VITE_*`)
|
||||||
|
|
||||||
|
| Variable | Description | Dev value |
|
||||||
|
| ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||||
|
| `VITE_API_URL` | Ky API base prefix, **baked into the build** at `vite build` | `/api` (dev server) / `https://api.dev.seahaven.com/api` (production build) |
|
||||||
|
| `VITE_API_TARGET` | Dev-proxy target for `/api` (Vite dev server only) | `http://localhost:5141` |
|
||||||
|
|
||||||
|
`VITE_API_URL` supplies the full API prefix — route paths in `API_PATHS` do
|
||||||
|
**not** include `/api`. Absolute values **must end with `/api`**;
|
||||||
|
`vite.config.ts` enforces this via `config/api-url-contract.ts` and fails the
|
||||||
|
build otherwise. See [`.env.example`](.env.example),
|
||||||
|
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||||
|
|
||||||
|
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||||
|
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Requirements: Node.js ≥ 22.22.1 (CI/CD run Node 24), npm 11.16.0 (pinned via
|
||||||
|
`packageManager`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
cp .env.example .env # then set VITE_API_URL=/api for local dev
|
||||||
|
npm run dev # Vite dev server on port 3000, proxies /api → VITE_API_TARGET
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
||||||
|
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||||
|
the deployed dev API).
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||||
|
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||||
|
| `npm run preview` | Preview the production build locally |
|
||||||
|
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||||
|
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||||
|
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||||
|
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||||
|
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||||
|
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||||
|
|
||||||
|
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||||
|
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||||
|
`npm run build`) before pushing to catch type errors early.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
- Branch from `dev` with a kebab-case description and a prefix matching the
|
||||||
|
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||||
|
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
||||||
|
- Commit messages follow
|
||||||
|
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||||
|
rejects anything else at commit time.
|
||||||
|
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
|
||||||
|
a green CI run and an approving review from a code owner
|
||||||
|
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||||
|
Merged branches are deleted automatically.
|
||||||
|
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
||||||
|
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
||||||
|
exists yet).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
||||||
|
|
||||||
|
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||||
|
and PRs to `main`/`dev`, calls
|
||||||
|
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||||
|
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||||
|
that calls `npm run verify` so every gate (including the maintainability
|
||||||
|
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
|
||||||
|
guaranteed from this repository. Conventions and gates are documented under
|
||||||
|
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||||
|
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||||
|
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||||
|
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||||
|
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||||
|
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||||
|
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||||
|
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||||
|
CloudFront invalidation. Both run as the OIDC deploy role.
|
||||||
|
|
||||||
|
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
||||||
|
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
||||||
|
[`infra/cdk/README.md`](infra/cdk/README.md).
|
||||||
|
|
||||||
|
Manual deploy (emergency/reference only — needs credentials for the
|
||||||
|
external-dev AWS account; the normal path is push to `dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd infra/cdk && npx cdk deploy)
|
||||||
|
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
|
||||||
|
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
|
||||||
|
directly and API calls succeed.
|
||||||
|
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||||
|
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||||
|
errors surface in the browser and on the backend API's side.
|
||||||
|
- **Common failure modes:**
|
||||||
|
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||||
|
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||||
|
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
|
||||||
|
on this repo; deploys from other branches/repos are rejected by design.
|
||||||
|
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||||
|
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||||
|
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||||
|
not proxy `/api`.
|
||||||
|
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
||||||
|
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
||||||
|
follow-up work).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||||
|
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||||
|
design system and UI docs under [`docs/`](docs/)
|
||||||
|
|
|
||||||
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
|
||||||
|
|
||||||
|
Every PR is reviewed against this contract, by humans and by review agents. The
|
||||||
|
goal is high-signal review: catch real behavior, security, performance, and
|
||||||
|
regression problems — not re-lint what the gates already enforce.
|
||||||
|
|
||||||
|
## 1. Review the exact head
|
||||||
|
|
||||||
|
- **MUST** review the diff at the **current PR head**, not a stale checkout.
|
||||||
|
Re-pull before reviewing if new commits landed; stale approvals are dismissed
|
||||||
|
on push by branch protection.
|
||||||
|
- **MUST** read the full diff of every changed file, including renames and
|
||||||
|
generated/mapper code, not only the "interesting" components.
|
||||||
|
|
||||||
|
## 2. Board-backed regression inventory
|
||||||
|
|
||||||
|
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
|
||||||
|
all visible tickets for this area, not only the current ticket. Behavior that
|
||||||
|
is Done / QA-approved / released is **protected scope**.
|
||||||
|
- **MUST** treat a plausible regression against protected behavior as a
|
||||||
|
**Blocker** until disproven with repo evidence (the accepting tests, the linked
|
||||||
|
PR/release, and a targeted check of the changed code paths).
|
||||||
|
- **MUST NOT** approve if board access or the relevant inventory is missing —
|
||||||
|
say so and block rather than infer a pass. A missing ticket link alone is not
|
||||||
|
a blocker, but known acceptance criteria must still be traced.
|
||||||
|
|
||||||
|
The current Jira workflow has no `Ready for QA` transition. Merged work remains
|
||||||
|
in the documented pre-QA status until QA evidence supports `Done`; do not invent
|
||||||
|
a status or mark unverified work Done.
|
||||||
|
|
||||||
|
## 3. Behavior-based testing
|
||||||
|
|
||||||
|
- **MUST** test through **public behavior** (rendered output, user interactions,
|
||||||
|
query/mutation outcomes), not internal implementation details. Prefer
|
||||||
|
`@testing-library` queries and user-event flows; assert what users observe.
|
||||||
|
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
|
||||||
|
governance script) executes — validate tooling by running the real gates
|
||||||
|
(`npm run verify`), not with assertion-free unit tests.
|
||||||
|
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
|
||||||
|
error/empty state, and any boundary the change introduces.
|
||||||
|
|
||||||
|
## 4. Security review
|
||||||
|
|
||||||
|
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
|
||||||
|
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
|
||||||
|
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
|
||||||
|
`VITE_*` variable carries a secret (it is baked into the bundle).
|
||||||
|
- **SHOULD** check untrusted input is validated (Zod) before use and that
|
||||||
|
dangerously-set HTML / unescaped server strings are not introduced.
|
||||||
|
|
||||||
|
## 5. Performance and Big-O review
|
||||||
|
|
||||||
|
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
|
||||||
|
collections, re-filtering/sorting on every render, unbounded list rendering
|
||||||
|
without virtualization.
|
||||||
|
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
|
||||||
|
the correct keys (no stale cache, no redundant refetch storms).
|
||||||
|
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
|
||||||
|
unstable identities passed to memoized children.
|
||||||
|
|
||||||
|
## 6. No style-only comments
|
||||||
|
|
||||||
|
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||||
|
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||||
|
gates; review is for behavior, correctness, security, and architecture.
|
||||||
|
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
|
||||||
|
evidence-based convention in these docs, and offer a concrete fix or a
|
||||||
|
targeted question. Use GitHub suggestion blocks when safe.
|
||||||
|
|
||||||
|
## 7. Review close-out
|
||||||
|
|
||||||
|
A review is complete when it records, briefly:
|
||||||
|
|
||||||
|
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
|
||||||
|
"no findings".
|
||||||
|
2. Open questions and their owner.
|
||||||
|
3. Which of the above checks were run, and any that were skipped (and why).
|
||||||
|
4. Residual risk, if approving.
|
||||||
|
|
||||||
|
Do not write a monolithic review body or a validation transcript into the PR
|
||||||
|
surface; keep comments inline and high-signal.
|
||||||
33
config/pwa-manifest.test.ts
Normal file
33
config/pwa-manifest.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const manifest = JSON.parse(readFileSync(path.join(root, "public/manifest.json"), "utf-8")) as {
|
||||||
|
name: string;
|
||||||
|
short_name: string;
|
||||||
|
theme_color: string;
|
||||||
|
icons: Array<{ src: string }>;
|
||||||
|
};
|
||||||
|
const indexHtml = readFileSync(path.join(root, "index.html"), "utf-8");
|
||||||
|
|
||||||
|
describe("PWA install metadata", () => {
|
||||||
|
it("identifies the app as Sea Haven, not template branding", () => {
|
||||||
|
expect(manifest.name).toContain("Sea Haven");
|
||||||
|
expect(manifest.short_name).not.toBe("React App");
|
||||||
|
expect(manifest.name).not.toContain("Create React App");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the design-system theme color, matching index.html", () => {
|
||||||
|
expect(manifest.theme_color).toBe("#1c75bc");
|
||||||
|
expect(indexHtml).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only references icon files that exist in public/", () => {
|
||||||
|
for (const icon of manifest.icons) {
|
||||||
|
expect(existsSync(path.join(root, "public", icon.src)), `missing public/${icon.src}`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Architecture Plan — SeaHaven (Selective Rebuild)
|
# Architecture Plan — Sea Haven (Selective Rebuild)
|
||||||
|
|
||||||
> Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI
|
> Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# SeaHaven Design System
|
# Sea Haven Design System
|
||||||
|
|
||||||
Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`).
|
Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`).
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ MUI typography variants in `src/lib/theme/mui-theme.ts` mirror this hierarchy.
|
||||||
|
|
||||||
### Prefer themed MUI in forms and data tables
|
### Prefer themed MUI in forms and data tables
|
||||||
|
|
||||||
MUI components inherit Seahaven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
|
MUI components inherit Sea Haven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
|
||||||
|
|
||||||
### Prefer semantic Tailwind in layout shells
|
### Prefer semantic Tailwind in layout shells
|
||||||
|
|
||||||
|
|
@ -119,9 +119,9 @@ import { cn } from "@/lib/utils";
|
||||||
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
|
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
|
||||||
| `DocBadge` | `components/domain/doc-badge.tsx` |
|
| `DocBadge` | `components/domain/doc-badge.tsx` |
|
||||||
|
|
||||||
## Legacy → Seahaven mapping
|
## Legacy → Sea Haven mapping
|
||||||
|
|
||||||
| Legacy | Seahaven | Consumption |
|
| Legacy | Sea Haven | Consumption |
|
||||||
| ------------------------- | ------------------------------ | -------------------------------- |
|
| ------------------------- | ------------------------------ | -------------------------------- |
|
||||||
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
|
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
|
||||||
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
|
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Design System Migration Log
|
# Design System Migration Log
|
||||||
|
|
||||||
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens. Strategy: **MUI-only**.
|
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Sea Haven DS tokens. Strategy: **MUI-only**.
|
||||||
|
|
||||||
## Phase 1 — Foundation
|
## Phase 1 — Foundation
|
||||||
|
|
||||||
| File | Action | Reason |
|
| File | Action | Reason |
|
||||||
| --------------------------- | ---------- | --------------------------------------------------------------------- |
|
| --------------------------- | ---------- | --------------------------------------------------------------------- |
|
||||||
| `src/styles/theme.css` | Created | SoT: 131+ Seahaven tokens + layout/typography/elevation extensions |
|
| `src/styles/theme.css` | Created | SoT: 131+ Sea Haven tokens + layout/typography/elevation extensions |
|
||||||
| `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` |
|
| `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` |
|
||||||
| `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles |
|
| `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles |
|
||||||
| `src/styles/motion.css` | Created | Duration and easing tokens |
|
| `src/styles/motion.css` | Created | Duration and easing tokens |
|
||||||
|
|
@ -29,7 +29,7 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens
|
||||||
| `src/components/layout/app-topbar.tsx` | Created | MUI AppBar + header gradient; replaces `Topbar.js` |
|
| `src/components/layout/app-topbar.tsx` | Created | MUI AppBar + header gradient; replaces `Topbar.js` |
|
||||||
| `src/app/(protected)/_layout.tsx` | Modified | Uses new layout components |
|
| `src/app/(protected)/_layout.tsx` | Modified | Uses new layout components |
|
||||||
| `src/main.tsx` | Modified | Removed `index.css` import |
|
| `src/main.tsx` | Modified | Removed `index.css` import |
|
||||||
| `src/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Seahaven CSS vars |
|
| `src/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Sea Haven CSS vars |
|
||||||
| `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell |
|
| `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell |
|
||||||
| `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` |
|
| `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` |
|
||||||
| `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` |
|
| `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` |
|
||||||
|
|
@ -59,7 +59,7 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens
|
||||||
| `src/app/(protected)/settings/dropdowns.tsx` | Modified | Semantic sidebar + active states |
|
| `src/app/(protected)/settings/dropdowns.tsx` | Modified | Semantic sidebar + active states |
|
||||||
| `src/app/(protected)/settings/task-templates.tsx` | Modified | Semantic borders/text |
|
| `src/app/(protected)/settings/task-templates.tsx` | Modified | Semantic borders/text |
|
||||||
| `src/components/common/settings-nav.tsx` | Modified | `text-primary`, `border-border` |
|
| `src/components/common/settings-nav.tsx` | Modified | `text-primary`, `border-border` |
|
||||||
| `src/components/common/calendar/calendar-utils.ts` | Modified | Bootstrap hex → Seahaven CSS vars |
|
| `src/components/common/calendar/calendar-utils.ts` | Modified | Bootstrap hex → Sea Haven CSS vars |
|
||||||
| `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes |
|
| `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes |
|
||||||
| `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes |
|
| `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes |
|
||||||
| `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars |
|
| `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars |
|
||||||
|
|
@ -79,8 +79,8 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens
|
||||||
|
|
||||||
- Primary color: `#0c4f6f` → `#1c75bc` (expected, documented)
|
- Primary color: `#0c4f6f` → `#1c75bc` (expected, documented)
|
||||||
- Body font: Segoe UI 13px → DM Sans 14px
|
- Body font: Segoe UI 13px → DM Sans 14px
|
||||||
- Sidebar: `#fbfbfb` → `#f6f8fb` with Seahaven active/hover states
|
- Sidebar: `#fbfbfb` → `#f6f8fb` with Sea Haven active/hover states
|
||||||
- Header: flat blue gradient → Seahaven multi-stop gradient
|
- Header: flat blue gradient → Sea Haven multi-stop gradient
|
||||||
|
|
||||||
## Not migrated (intentional)
|
## Not migrated (intentional)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Feature Prioritization Matrix — SeaHaven
|
# Feature Prioritization Matrix — Sea Haven
|
||||||
|
|
||||||
> Source of truth for legacy → `domain/` + `app/` migration order.
|
> Source of truth for legacy → `domain/` + `app/` migration order.
|
||||||
> Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
> Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
||||||
|
|
|
||||||
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Frontend maintainability conventions
|
||||||
|
|
||||||
|
## Conditional rendering
|
||||||
|
|
||||||
|
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||||
|
state. Use a ternary only when both branches render meaningful alternatives.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
error && <Alert severity="error">{error.message}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
<Text variant="description" when={Boolean(description)}>
|
||||||
|
{description}
|
||||||
|
</Text>;
|
||||||
|
```
|
||||||
|
|
||||||
|
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||||
|
distinct from real either-or UI decisions.
|
||||||
|
|
||||||
|
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
|
||||||
|
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
|
||||||
|
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
|
||||||
|
TypeScript checker for the type of the left operand and reports unless every union constituent is
|
||||||
|
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
|
||||||
|
fails closed: if type services are unavailable in a governed TSX file it reports rather than
|
||||||
|
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
|
||||||
|
same constraint at that component boundary.
|
||||||
|
|
||||||
|
Approved guard forms (choose by semantics, not as a blind codemod):
|
||||||
|
|
||||||
|
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
|
||||||
|
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
|
||||||
|
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
|
||||||
|
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
|
||||||
|
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
|
||||||
|
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
|
||||||
|
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
|
||||||
|
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
|
||||||
|
narrowing.
|
||||||
|
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
|
||||||
|
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
|
||||||
|
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
|
||||||
|
|
||||||
|
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
|
||||||
|
that needs a non-boolean operand must be rewritten into one of the approved forms above.
|
||||||
|
|
||||||
|
## Typography and feedback
|
||||||
|
|
||||||
|
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||||
|
code, and asynchronous feedback. It owns:
|
||||||
|
|
||||||
|
- semantic HTML for each visual variant;
|
||||||
|
- the display, body, and monospace font families;
|
||||||
|
- default, muted, error, success, and warning tones;
|
||||||
|
- accessible `alert` and `status` live regions for error and feedback text;
|
||||||
|
- one-sided conditional text through `when`.
|
||||||
|
|
||||||
|
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
|
||||||
|
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
|
||||||
|
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
|
||||||
|
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
|
||||||
|
`aria-live` directly.
|
||||||
|
|
||||||
|
Live-region behavior:
|
||||||
|
|
||||||
|
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
|
||||||
|
the polite region exists before its content changes and is announced reliably. Mounting an
|
||||||
|
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
|
||||||
|
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
|
||||||
|
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
|
||||||
|
|
||||||
|
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
|
||||||
|
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
|
||||||
|
|
||||||
|
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
|
||||||
|
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
|
||||||
|
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
|
||||||
|
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
|
||||||
|
static surface only. It cannot resolve dynamic or composed class values
|
||||||
|
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
|
||||||
|
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
|
||||||
|
|
||||||
|
## Forms and mutations
|
||||||
|
|
||||||
|
Use the libraries already established in the application:
|
||||||
|
|
||||||
|
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||||
|
- Zod owns form validation and inferred form value types.
|
||||||
|
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||||
|
invalidation, and retries where safe.
|
||||||
|
|
||||||
|
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||||
|
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||||
|
benchmarks, a codemod plan, and an approved deprecation path.
|
||||||
|
|
||||||
|
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||||
|
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||||
|
retry state. Do not place upload orchestration in a route-sized page component.
|
||||||
|
|
||||||
|
## Page state
|
||||||
|
|
||||||
|
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||||
|
|
||||||
|
- query loading, error, and empty states stay adjacent to the query result;
|
||||||
|
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||||
|
- route pages coordinate sections and navigation;
|
||||||
|
- reusable sections own their interaction details;
|
||||||
|
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||||
|
|
||||||
|
## Enforcement and rollout
|
||||||
|
|
||||||
|
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||||
|
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||||
|
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# SeaHaven UI - Complete Architecture Documentation
|
# Sea Haven UI - Complete Architecture Documentation
|
||||||
|
|
||||||
> Modern React architecture with Redux Toolkit, React Query, and CSS Modules
|
> Modern React architecture with Redux Toolkit, React Query, and CSS Modules
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
│ 5. BACKEND │
|
│ 5. BACKEND │
|
||||||
│ │
|
│ │
|
||||||
│ REST API │
|
│ REST API │
|
||||||
│ (SeaHaven Backend) │
|
│ (Sea Haven Backend) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -539,6 +539,6 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Welcome to the modern SeaHaven UI! 🎊**
|
**Welcome to the modern Sea Haven UI! 🎊**
|
||||||
|
|
||||||
_Last updated: 2026_
|
_Last updated: 2026_
|
||||||
|
|
|
||||||
148
docs/work-orders/board-search-api.md
Normal file
148
docs/work-orders/board-search-api.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Work Orders Board & Search API
|
||||||
|
|
||||||
|
Frontend integration contract for weekly board and advanced search filters.
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
| ------------ | ------------------------------------------- |
|
||||||
|
| Prefixo | `/api/workorders` (alias: `/api/WorkOrder`) |
|
||||||
|
| Auth | `Authorization: Bearer {token}` (JWT) |
|
||||||
|
| Content-Type | Não necessário (GET) |
|
||||||
|
|
||||||
|
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
||||||
|
|
||||||
|
## Quando usar qual endpoint
|
||||||
|
|
||||||
|
| Modo FE | Endpoint | Quando |
|
||||||
|
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
|
||||||
|
| Barra principal (`advApplied === null`) | `GET /board` | Semana + dispatchers + tipo segment + search |
|
||||||
|
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
|
||||||
|
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
|
||||||
|
|
||||||
|
**Não misture parâmetros:** `weekStart`/`weekEnd` só no `/board`; `datePreset` só no `/search`.
|
||||||
|
|
||||||
|
## Query builders (FE)
|
||||||
|
|
||||||
|
- [`board-query-params.ts`](../../src/domain/work-orders/utils/board-query-params.ts) — `toBoardQueryParams`, `toBoardSearchQueryParams`
|
||||||
|
- Arrays na query: repetição simples (`types=2&types=6`), via `toUrlSearchParams` em `work-orders-api.ts`
|
||||||
|
|
||||||
|
## Barra → `GET /board`
|
||||||
|
|
||||||
|
| FE state | API param | Regra |
|
||||||
|
| --------------- | ---------------------- | ----------------------------------------------------------- |
|
||||||
|
| `search` | `search` | Enviar só se `length >= 2`; com 1 char, filtrar client-side |
|
||||||
|
| `weekMonday` | `weekStart` | ISO `YYYY-MM-DD` (segunda) |
|
||||||
|
| — | `weekEnd` | `weekStart + 4` (sexta) |
|
||||||
|
| `dispatcherIds` | `dispatchers` | `__unassigned` → `__unassigned__` |
|
||||||
|
| "My WOs" | `dispatchers={userId}` | Equivalente a `myWorkOrders=true` |
|
||||||
|
| `typeFilter` | `types` / `overdue` | Tipos reais em `types`; past-due via `overdue=true` (OR) |
|
||||||
|
|
||||||
|
## Advanced → `GET /board/search`
|
||||||
|
|
||||||
|
| FE state | API param | Regra |
|
||||||
|
| ---------------- | --------------------- | ------------------------------------------- |
|
||||||
|
| `page` | `page` | **0-based** (primeira página = `0`) |
|
||||||
|
| `pageSize` | `pageSize` | Default `100`, max `100` |
|
||||||
|
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
|
||||||
|
| `sortDir` | `sortDir` | `asc` \| `desc` |
|
||||||
|
| `search` | `search` | `>= 2` chars |
|
||||||
|
| `dateRange` | `datePreset` | PascalCase (ver abaixo) |
|
||||||
|
| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` |
|
||||||
|
| `sites` (IDs UI) | `sites` | **Site codes**, não location ID |
|
||||||
|
| `types` | `types` | Integers de `WorkOrderType` reais |
|
||||||
|
| overdue (UI) | `overdue` | `true` = past-due (`isPastDue`); ver abaixo |
|
||||||
|
| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` |
|
||||||
|
| `statuses` | `statuses` | Integers 1–10 |
|
||||||
|
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
|
||||||
|
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
|
||||||
|
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
|
||||||
|
|
||||||
|
### `datePreset`
|
||||||
|
|
||||||
|
| FE key | API value |
|
||||||
|
| --------------- | ------------- |
|
||||||
|
| `this-week` | `ThisWeek` |
|
||||||
|
| `last-week` | `LastWeek` |
|
||||||
|
| `this-month` | `ThisMonth` |
|
||||||
|
| `last-3-months` | `Last3Months` |
|
||||||
|
| `next-week` | `NextWeek` |
|
||||||
|
| `next-month` | `NextMonth` |
|
||||||
|
| `custom` | `Custom` |
|
||||||
|
|
||||||
|
### `types` — WorkOrderType
|
||||||
|
|
||||||
|
Somente tipos reais de work order. **Não** use sentinel `99` para overdue.
|
||||||
|
|
||||||
|
| Label FE | API |
|
||||||
|
| --------- | -------------------------------------------- |
|
||||||
|
| PM | `2` |
|
||||||
|
| Emergency | `3` |
|
||||||
|
| Reactive | `6` |
|
||||||
|
| Add-On | `7` |
|
||||||
|
| Other | tipo real do enum (não é filtro de past-due) |
|
||||||
|
|
||||||
|
### `overdue` — past-due
|
||||||
|
|
||||||
|
| FE / UI | API param | Regra |
|
||||||
|
| ------- | -------------- | ----------------------------------------- |
|
||||||
|
| Overdue | `overdue=true` | Filtra work orders past-due (`isPastDue`) |
|
||||||
|
|
||||||
|
`types` e `overdue` combinam com **OR**: uma WO entra no resultado se corresponder a algum `types` **ou** estiver overdue (quando `overdue=true`).
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
|
||||||
|
- Só PM e Emergency: `types=2&types=3`
|
||||||
|
- Só past-due: `overdue=true`
|
||||||
|
- PM **ou** past-due: `types=2&overdue=true`
|
||||||
|
|
||||||
|
### `statuses` — LifecycleStatus
|
||||||
|
|
||||||
|
Incomplete=1, Pending=2, Scheduled=3, En Route=4, On Site=5, In Progress=6, Completed=7, Rescheduled=8, Canceled=9, Pending Quote=10.
|
||||||
|
|
||||||
|
### `docStatuses`
|
||||||
|
|
||||||
|
Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3.
|
||||||
|
|
||||||
|
## Respostas
|
||||||
|
|
||||||
|
### `/board`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"weekStart": "2026-07-14",
|
||||||
|
"weekEnd": "2026-07-18",
|
||||||
|
"counts": { "total": 42, "returned": 10 },
|
||||||
|
"scheduled": [],
|
||||||
|
"unscheduled": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/board/search`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"totalCount": 150,
|
||||||
|
"page": 0,
|
||||||
|
"pageSize": 100,
|
||||||
|
"totalPages": 2,
|
||||||
|
"hasPrevious": false,
|
||||||
|
"hasNext": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erros HTTP
|
||||||
|
|
||||||
|
| Situação | Status |
|
||||||
|
| --------------------------------------------------- | ------ |
|
||||||
|
| `weekEnd < weekStart` ou janela > 7 dias | 400 |
|
||||||
|
| `datePreset=Custom` sem datas / `dateTo < dateFrom` | 400 |
|
||||||
|
| `sortBy` inválido | 400 |
|
||||||
|
| Sem token / expirado | 401 |
|
||||||
|
|
||||||
|
Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`.
|
||||||
|
|
||||||
|
## Validação UI
|
||||||
|
|
||||||
|
O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário.
|
||||||
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "vendor-operations-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Vendor Operations Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(page: Page) {
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Vendor operations", () => {
|
||||||
|
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "no-vendor-501",
|
||||||
|
type: "NoVendor",
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ label: "WO-501", href: "/workorders/501" },
|
||||||
|
{ label: "WO-502", href: "/workorders/502" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
"href",
|
||||||
|
"/workorders/501",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/workorders/502",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
const requestedUrls: string[] = [];
|
||||||
|
await page.route("**/api/vendor-operations/insights*", async (route) => {
|
||||||
|
requestedUrls.push(route.request().url());
|
||||||
|
const { pathname } = new URL(route.request().url());
|
||||||
|
if (pathname.endsWith(".csv")) {
|
||||||
|
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname.endsWith(".pdf")) {
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
vendorId: 7,
|
||||||
|
vendor: "Gateway Plumbing",
|
||||||
|
completedJobs: 12,
|
||||||
|
cancellationRefusalRate: 0.1,
|
||||||
|
onTimeArrivalRate: 0.9,
|
||||||
|
onTimeCompletionRate: 0.8,
|
||||||
|
averageCycleHours: 5.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/vendors/insights");
|
||||||
|
|
||||||
|
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
|
||||||
|
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
|
||||||
|
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
|
||||||
|
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
|
||||||
|
|
||||||
|
const insightsQueryHas = (name: string, value: string) =>
|
||||||
|
requestedUrls.some(
|
||||||
|
(url) =>
|
||||||
|
new URL(url).pathname.endsWith("/insights") &&
|
||||||
|
new URL(url).searchParams.get(name) === value,
|
||||||
|
);
|
||||||
|
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
|
||||||
|
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
|
||||||
|
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Export CSV" }).click();
|
||||||
|
await page.getByRole("button", { name: "Export PDF" }).click();
|
||||||
|
|
||||||
|
const exportRequest = (format: "csv" | "pdf") =>
|
||||||
|
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
|
||||||
|
const exportHasFilters = (url: string | undefined) => {
|
||||||
|
if (!url) return false;
|
||||||
|
const params = new URL(url).searchParams;
|
||||||
|
return (
|
||||||
|
params.get("from") === "2026-01-01" &&
|
||||||
|
params.get("to") === "2026-01-31" &&
|
||||||
|
params.get("trade") === "Plumbing"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
|
||||||
|
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reorders and saves preferred vendors for a site", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
let savedPreferences: unknown;
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/Dropdown*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
|
||||||
|
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
savedPreferences = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, savedPreferences);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fulfillJson(route, [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
locationId: 10,
|
||||||
|
vendorId: 7,
|
||||||
|
vendorName: "Gateway Plumbing",
|
||||||
|
trade: "Plumbing",
|
||||||
|
sortOrder: 0,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
locationId: 10,
|
||||||
|
vendorId: 8,
|
||||||
|
vendorName: "Metro HVAC",
|
||||||
|
trade: "HVAC",
|
||||||
|
sortOrder: 1,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/vendors/preferences");
|
||||||
|
await page.getByLabel("Site").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||||
|
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Save preferences" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => savedPreferences)
|
||||||
|
.toEqual([
|
||||||
|
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
|
||||||
|
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: unknown;
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 900,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, {
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
conflicts: [
|
||||||
|
{
|
||||||
|
workOrderId: 502,
|
||||||
|
workOrderNumber: "WO-502",
|
||||||
|
start: "2026-07-25T12:00:00Z",
|
||||||
|
end: "2026-07-25T14:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 900,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/new");
|
||||||
|
await page.getByLabel("Title").fill("Boiler repair");
|
||||||
|
await page.getByLabel("Location").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await page.getByLabel("Priority").click();
|
||||||
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||||
|
await page.getByLabel("Scheduled").fill("2026-07-25");
|
||||||
|
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
|
||||||
|
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||||
|
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
|
||||||
|
await page.getByLabel("Manual ETA override").check();
|
||||||
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => assignment)
|
||||||
|
.toEqual({
|
||||||
|
vendorId: 7,
|
||||||
|
scheduledStart: "2026-07-25T10:00:00.000Z",
|
||||||
|
scheduledEnd: "2026-07-25T12:00:00.000Z",
|
||||||
|
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
|
||||||
|
etaManualOverride: true,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/workorders\/900$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const workOrder = {
|
||||||
|
Id: 901,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
CreatedDate: "2026-07-20",
|
||||||
|
ScheduledDate: "2026-07-25",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [
|
||||||
|
{
|
||||||
|
Id: 44,
|
||||||
|
VendorId: 7,
|
||||||
|
Status: "Acknowledged",
|
||||||
|
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
|
||||||
|
EtaManualOverride: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, { data: workOrder }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
|
||||||
|
fulfillJson(route, { data: workOrder }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workorders/edit/901");
|
||||||
|
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
|
||||||
|
await page.getByRole("button", { name: "Update" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
|
||||||
|
// The window below straddles the transition: 01:00 is EST (UTC-5) while
|
||||||
|
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
|
||||||
|
// fixed-window assumption remains.
|
||||||
|
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await authenticate(page);
|
||||||
|
let assignment: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 902,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
|
||||||
|
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||||
|
});
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 902,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/new");
|
||||||
|
await page.getByLabel("Title").fill("Boiler repair");
|
||||||
|
await page.getByLabel("Location").click();
|
||||||
|
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
|
await page.getByLabel("Priority").click();
|
||||||
|
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||||
|
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
|
||||||
|
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
|
||||||
|
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||||
|
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
availabilityStatus: "Available",
|
||||||
|
isPreferred: true,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 501,
|
||||||
|
WorkerOrderTitle: "Boiler repair",
|
||||||
|
LocationId: 10,
|
||||||
|
Priority: "Sev 1",
|
||||||
|
Status: "Open",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/workorders/edit/501?assignVendor=1");
|
||||||
|
|
||||||
|
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
|
||||||
|
await expect(vendorSection).toBeVisible();
|
||||||
|
await expect(vendorSection).toBeFocused();
|
||||||
|
await page.getByLabel("Company + Technician").click();
|
||||||
|
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
|
||||||
|
await expect(option).toContainText("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
410
e2e/vendors/vendors.spec.ts
vendored
Normal file
410
e2e/vendors/vendors.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "vendor-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Vendor E2E Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeVendors = [
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
CompanyId: 101,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Phone: "314-555-0198",
|
||||||
|
CompanyPhone: "314-555-0100",
|
||||||
|
Address: "1 Market St",
|
||||||
|
City: "St. Louis",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63101",
|
||||||
|
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||||
|
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
Notes: "Preferred after-hours vendor",
|
||||||
|
TotalJobs: 72,
|
||||||
|
IsActive: true,
|
||||||
|
PreferredContact: "Email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
CompanyId: 102,
|
||||||
|
CompanyName: "Metro HVAC",
|
||||||
|
ContactName: "Maya Chen",
|
||||||
|
Email: "maya@metro.test",
|
||||||
|
Phone: "314-555-0112",
|
||||||
|
CompanyPhone: "314-555-0110",
|
||||||
|
Address: "2 Pine St",
|
||||||
|
City: "Clayton",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63105",
|
||||||
|
TradeSpecialties: "HVAC",
|
||||||
|
GoogleMapsUrl: "",
|
||||||
|
Notes: "",
|
||||||
|
TotalJobs: 18,
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const inactiveVendors = [
|
||||||
|
{
|
||||||
|
...activeVendors[0],
|
||||||
|
Id: 3,
|
||||||
|
CompanyName: "Drain Cleaning Bros",
|
||||||
|
ContactName: "Drew Banks",
|
||||||
|
TotalJobs: 151,
|
||||||
|
IsActive: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MockState {
|
||||||
|
listUrls: string[];
|
||||||
|
createdBody?: Record<string, unknown>;
|
||||||
|
updatedBody?: Record<string, unknown>;
|
||||||
|
deletedId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureVisual(page: Page, path: string) {
|
||||||
|
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockVendorApi(
|
||||||
|
page: Page,
|
||||||
|
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const state: MockState = { listUrls: [] };
|
||||||
|
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
||||||
|
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ id: 1, value: "Plumbing" },
|
||||||
|
{ id: 2, value: "HVAC" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/facets*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
companies: activeVendors.map((vendor) => ({
|
||||||
|
companyId: vendor.CompanyId,
|
||||||
|
name: vendor.CompanyName,
|
||||||
|
companyPhone: vendor.CompanyPhone,
|
||||||
|
email: vendor.Email,
|
||||||
|
address: vendor.Address,
|
||||||
|
city: vendor.City,
|
||||||
|
state: vendor.State,
|
||||||
|
zip: vendor.Zip,
|
||||||
|
googleMapsUrl: vendor.GoogleMapsUrl,
|
||||||
|
})),
|
||||||
|
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||||
|
locations: [
|
||||||
|
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||||
|
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||||
|
],
|
||||||
|
jobBuckets: [
|
||||||
|
{ id: "under-50", label: "Under 50" },
|
||||||
|
{ id: "50-99", label: "50–99" },
|
||||||
|
{ id: "100-149", label: "100–149" },
|
||||||
|
{ id: "150-plus", label: "150+" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
state.listUrls.push(url.toString());
|
||||||
|
if (options.listError && url.searchParams.get("pageSize") !== "1") {
|
||||||
|
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = url.searchParams.get("isActive") !== "false";
|
||||||
|
const source = isActive ? activeVendors : inactiveVendors;
|
||||||
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||||
|
const company = url.searchParams.get("companies[0]");
|
||||||
|
const filtered = source.filter(
|
||||||
|
(vendor) =>
|
||||||
|
(!search ||
|
||||||
|
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)) &&
|
||||||
|
(!company || vendor.CompanyName === company),
|
||||||
|
);
|
||||||
|
const isCount = url.searchParams.get("pageSize") === "1";
|
||||||
|
await fulfillJson(route, {
|
||||||
|
Data: isCount ? filtered.slice(0, 1) : filtered,
|
||||||
|
TotalCount: filtered.length,
|
||||||
|
PageNumber: 1,
|
||||||
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||||
|
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
vendorId,
|
||||||
|
canDeactivate: !options.deactivationBlocked,
|
||||||
|
openWorkOrders: options.deactivationBlocked
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
workOrderTitle: "Emergency boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2026-07-24T14:00:00Z",
|
||||||
|
dispatchId: 801,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||||
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
state.updatedBody = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
if (options.deleteConflict) {
|
||||||
|
await fulfillJson(
|
||||||
|
route,
|
||||||
|
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.deletedId = id;
|
||||||
|
await fulfillJson(route, { status: "200" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
|
||||||
|
await fulfillJson(route, { data: vendor });
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.createdBody = route.request().postDataJSON();
|
||||||
|
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Vendor directory prototype parity", () => {
|
||||||
|
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await mockVendorApi(page);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||||
|
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
for (const heading of [
|
||||||
|
"Company",
|
||||||
|
"Technician",
|
||||||
|
"Location",
|
||||||
|
"Trade",
|
||||||
|
"Phone",
|
||||||
|
"Email",
|
||||||
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
|
]) {
|
||||||
|
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||||
|
await expect(page.getByText("+1")).toBeVisible();
|
||||||
|
await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible();
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
|
||||||
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||||
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||||
|
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Search name, company, trade...").clear();
|
||||||
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||||
|
await page.getByRole("button", { name: /Company/ }).click();
|
||||||
|
await page.getByPlaceholder("All companies").click();
|
||||||
|
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||||
|
expect(
|
||||||
|
state.listUrls.some(
|
||||||
|
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
"history will be preserved",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||||
|
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates with company autofill, then views and edits every detail field", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await mockVendorApi(page);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||||
|
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel("Technician Name").fill("New Technician");
|
||||||
|
await page.getByRole("combobox", { name: "Company" }).click();
|
||||||
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||||
|
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||||
|
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
|
||||||
|
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||||
|
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||||
|
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
||||||
|
await page.getByRole("button", { name: "Add trade" }).click();
|
||||||
|
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
|
||||||
|
await page.getByRole("button", { name: "Add trade" }).click();
|
||||||
|
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||||
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
|
await page.getByRole("button", { name: "Text" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||||
|
expect(state.createdBody).toMatchObject({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
companyId: 101,
|
||||||
|
contactName: "New Technician",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
preferredContact: "Text",
|
||||||
|
tradeSpecialties: "HVAC, Plumbing",
|
||||||
|
notes: "Created in browser E2E",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
|
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||||
|
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
|
||||||
|
).toHaveAttribute("target", "_blank");
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||||
|
|
||||||
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
|
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
|
||||||
|
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
|
||||||
|
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
|
||||||
|
await detailDrawer.getByRole("switch", { name: "Active" }).click();
|
||||||
|
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||||
|
expect(state.updatedBody?.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||||
|
const state = await mockVendorApi(page);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||||
|
await addButton.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||||
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
|
await addButton.click();
|
||||||
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
|
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||||
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
|
||||||
|
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
||||||
|
expect(state.createdBody?.companyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||||
|
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||||
|
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
expect(blockedState.deletedId).toBeUndefined();
|
||||||
|
|
||||||
|
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await page.unrouteAll({ behavior: "wait" });
|
||||||
|
|
||||||
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /^Deactivate$/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
/open work orders|conflict/i,
|
||||||
|
);
|
||||||
|
expect(racedState.deletedId).toBeUndefined();
|
||||||
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await mockVendorApi(page, { listError: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||||
|
/Vendor directory unavailable|failed|server error/i,
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
|
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||||
|
await expect(filterDrawer).toBeVisible();
|
||||||
|
const box = await filterDrawer.boundingBox();
|
||||||
|
expect(box?.width).toBeLessThanOrEqual(391);
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||||
|
await page.getByRole("button", { name: "Close filters" }).click();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { expect, test, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "work-order-vendor-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Work Order E2E Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("edits only the vendor assigned to the current work order", async ({ page }) => {
|
||||||
|
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
Id: 501,
|
||||||
|
InternalWONumber: "WO-501",
|
||||||
|
WorkerOrderNumber: "CUSTOMER-501",
|
||||||
|
WorkerOrderTitle: "Emergency boiler repair",
|
||||||
|
Status: "Dispatched",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [
|
||||||
|
{
|
||||||
|
Id: 801,
|
||||||
|
DispatchNumber: "D-801",
|
||||||
|
VendorId: 7,
|
||||||
|
VendorName: "Gateway Plumbing",
|
||||||
|
VendorEmail: "dispatch@gateway.test",
|
||||||
|
Status: "Sent",
|
||||||
|
DispatchedAt: "2026-07-23T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendors/7/work-order-update", async (route) => {
|
||||||
|
updateRequest = {
|
||||||
|
url: route.request().url(),
|
||||||
|
body: route.request().postDataJSON() as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
await fulfillJson(route, { data: { id: 7 } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/vendors/7", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 7,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
PreferredContact: "Email",
|
||||||
|
Phone: "3145550198",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Notes: "Preferred after-hours vendor",
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
|
||||||
|
await page.goto("/workorders/501");
|
||||||
|
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Edit vendor" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
|
||||||
|
await page.getByLabel("Technician name").fill("Taylor Tech");
|
||||||
|
await page.getByLabel("Preferred contact").click();
|
||||||
|
await page.getByRole("option", { name: "Text" }).click();
|
||||||
|
await page.getByLabel("Phone").fill("3145550111");
|
||||||
|
await page.getByLabel("Email").fill("taylor@example.com");
|
||||||
|
await page.getByLabel("Notes").fill("Text before arrival");
|
||||||
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
|
||||||
|
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
|
||||||
|
expect(updateRequest?.body).toEqual({
|
||||||
|
workOrderId: 501,
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
preferredContact: "Text",
|
||||||
|
phone: "(314) 555-0111",
|
||||||
|
email: "taylor@example.com",
|
||||||
|
notes: "Text before arrival",
|
||||||
|
});
|
||||||
|
});
|
||||||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
|
||||||
|
* boolean.
|
||||||
|
*
|
||||||
|
* `{value && <Element />}` renders its left operand when it is falsy and
|
||||||
|
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
|
||||||
|
* must be `boolean` (or boolean literals) across the whole union. The rule
|
||||||
|
* asks the TypeScript checker for the type of the left operand and reports
|
||||||
|
* unless every union constituent is boolean-like.
|
||||||
|
*
|
||||||
|
* Type-aware by design: a selector that cannot see operand types would give
|
||||||
|
* false confidence rather than enforcement. If type services are unexpectedly
|
||||||
|
* unavailable in a governed TSX file, the rule fails closed (reports) instead
|
||||||
|
* of silently claiming the expression is safe.
|
||||||
|
*/
|
||||||
|
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
|
||||||
|
|
||||||
|
function isBooleanLikeType(type) {
|
||||||
|
return (type.flags & booleanLikeFlags) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntirelyBoolean(type) {
|
||||||
|
if (type.isUnion()) {
|
||||||
|
return type.types.every((constituent) => isBooleanLikeType(constituent));
|
||||||
|
}
|
||||||
|
return isBooleanLikeType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transparentExpressionTypes = new Set([
|
||||||
|
"ChainExpression",
|
||||||
|
"ParenthesizedExpression",
|
||||||
|
"TSAsExpression",
|
||||||
|
"TSNonNullExpression",
|
||||||
|
"TSTypeAssertion",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isInRenderedPosition(node) {
|
||||||
|
let current = node;
|
||||||
|
while (current.parent) {
|
||||||
|
const parent = current.parent;
|
||||||
|
if (parent.type === "JSXExpressionContainer") {
|
||||||
|
return parent.parent?.type !== "JSXAttribute";
|
||||||
|
}
|
||||||
|
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
|
||||||
|
current = parent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description: "Require the left operand of `&&` in JSX to be entirely boolean",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
nonBooleanLeft:
|
||||||
|
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
|
||||||
|
typeServicesUnavailable:
|
||||||
|
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
||||||
|
|
||||||
|
return {
|
||||||
|
LogicalExpression(node) {
|
||||||
|
if (node.operator !== "&&") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isInRenderedPosition(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!services || services.program === null) {
|
||||||
|
context.report({ node, messageId: "typeServicesUnavailable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftType = services.getTypeAtLocation(node.left);
|
||||||
|
if (!isEntirelyBoolean(leftType)) {
|
||||||
|
context.report({ node: node.left, messageId: "nonBooleanLeft" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default rule;
|
||||||
55
eslint-rules/no-vp-error-outside-text.js
Normal file
55
eslint-rules/no-vp-error-outside-text.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
function containsVpErrorToken(node) {
|
||||||
|
if (!node || typeof node !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "Literal" && typeof node.value === "string") {
|
||||||
|
return node.value.split(/\s+/u).includes("vp-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "TemplateElement") {
|
||||||
|
return node.value.raw.split(/\s+/u).includes("vp-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(node).some(([key, value]) => {
|
||||||
|
if (key === "parent") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.some(containsVpErrorToken);
|
||||||
|
}
|
||||||
|
return containsVpErrorToken(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description: "Require the Text component for legacy vp-error styling",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
useText:
|
||||||
|
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
return {
|
||||||
|
JSXAttribute(node) {
|
||||||
|
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementName = node.parent?.name;
|
||||||
|
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({ node, messageId: "useText" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default rule;
|
||||||
|
|
@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
|
||||||
|
import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
|
||||||
|
|
||||||
|
const localRules = {
|
||||||
|
rules: {
|
||||||
|
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
|
||||||
|
"no-vp-error-outside-text": noVpErrorOutsideText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const legacyIgnores = [
|
const legacyIgnores = [
|
||||||
"src/pages/**",
|
"src/pages/**",
|
||||||
"src/app/store.js",
|
"src/app/store.js",
|
||||||
|
|
@ -54,6 +64,7 @@ export default tseslint.config(
|
||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": pluginReactHooks,
|
"react-hooks": pluginReactHooks,
|
||||||
"react-refresh": pluginReactRefresh,
|
"react-refresh": pluginReactRefresh,
|
||||||
|
seahaven: localRules,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...pluginReactHooks.configs.recommended.rules,
|
...pluginReactHooks.configs.recommended.rules,
|
||||||
|
|
@ -62,6 +73,23 @@ export default tseslint.config(
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"seahaven/no-non-boolean-jsx-and": "error",
|
||||||
|
"seahaven/no-vp-error-outside-text": "error",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
|
||||||
|
message:
|
||||||
|
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
|
||||||
|
message:
|
||||||
|
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#1c75bc" />
|
||||||
<meta name="description" content="Seahaven - Officetrax" />
|
<meta name="description" content="Sea Haven - Work Orders" />
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<title>Seahaven - Officetrax</title>
|
<title>Sea Haven - Work Orders</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Infrastructure & CI/CD — SeaHaven SHOC frontend
|
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||||
|
|
||||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
||||||
deployed through the org's **reusable** GitHub Actions workflow.
|
deployed through the org's **reusable** GitHub Actions workflow.
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export interface FrontendStackProps extends StackProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static SPA hosting for the SeaHaven SHOC frontend:
|
* Static SPA hosting for the Sea Haven SHOC frontend:
|
||||||
* - private S3 bucket (no public access; CloudFront reads it via OAC)
|
* - private S3 bucket (no public access; CloudFront reads it via OAC)
|
||||||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||||
* - a GitHub Actions OIDC deploy role
|
* - a GitHub Actions OIDC deploy role
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "shoc-frontend-infra",
|
"name": "shoc-frontend-infra",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the SeaHaven SHOC frontend.",
|
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.",
|
||||||
"bin": {
|
"bin": {
|
||||||
"app": "bin/app.ts"
|
"app": "bin/app.ts"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"lint": "eslint .",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"governance": "node scripts/governance-check.mjs",
|
||||||
|
"verify": "npm run format:check && npm run lint && npm run build && npm test && npm run governance",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||||
baseURL: "http://localhost:4173",
|
baseURL: "http://localhost:4173",
|
||||||
|
// Pin the browser timezone to UTC so date-to-ISO assertions in the default
|
||||||
|
// create flow are deterministic regardless of host locale. DST-transition
|
||||||
|
// scenarios override this with their own non-UTC context.
|
||||||
|
timezoneId: "UTC",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.3 KiB |
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "Work Orders",
|
||||||
"name": "Create React App Sample",
|
"name": "Sea Haven - Work Orders",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -20,6 +20,6 @@
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#1c75bc",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
scripts/governance-baseline.json
Normal file
27
scripts/governance-baseline.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||||
|
"maxFileLines": 500,
|
||||||
|
"godfileDebt": [
|
||||||
|
{
|
||||||
|
"path": "src/app/(protected)/workorders/[id].tsx",
|
||||||
|
"maxLines": 797,
|
||||||
|
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx",
|
||||||
|
"maxLines": 782,
|
||||||
|
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/app/(protected)/vendors/index.tsx",
|
||||||
|
"maxLines": 612,
|
||||||
|
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
|
||||||
|
"maxLines": 572,
|
||||||
|
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
289
scripts/governance-check.mjs
Normal file
289
scripts/governance-check.mjs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
const MAX_FILE_LINES = 500;
|
||||||
|
const MAINTAINABILITY_RULES = [
|
||||||
|
'complexity: ["error", { "max": 20 }]',
|
||||||
|
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
|
||||||
|
'max-params: ["error", 4]',
|
||||||
|
'max-depth: ["error", 4]',
|
||||||
|
];
|
||||||
|
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||||
|
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||||
|
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||||
|
|
||||||
|
function isGoverned(relativePath) {
|
||||||
|
return (
|
||||||
|
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
|
||||||
|
/\.(ts|tsx)$/.test(relativePath) &&
|
||||||
|
!EXCLUDE_DIR.test(relativePath) &&
|
||||||
|
!EXCLUDE_NAME.test(relativePath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitText(args) {
|
||||||
|
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitLines(args) {
|
||||||
|
return gitText(args).split("\n").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function governedFiles() {
|
||||||
|
const tracked = gitLines(["ls-files"]);
|
||||||
|
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||||
|
return [...new Set([...tracked, ...untracked])].filter(isGoverned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineCount(relativePath) {
|
||||||
|
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||||
|
if (content.length === 0) return 0;
|
||||||
|
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBaseline() {
|
||||||
|
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBaselineAtRef(ref) {
|
||||||
|
try {
|
||||||
|
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function godfileRatchet(baseRef) {
|
||||||
|
const baseline = readBaseline();
|
||||||
|
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
|
||||||
|
const debtEntries = new Map(
|
||||||
|
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||||
|
);
|
||||||
|
const files = governedFiles();
|
||||||
|
|
||||||
|
const newDebt = [];
|
||||||
|
const grownDebt = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const lines = lineCount(file);
|
||||||
|
const debtCap = debtEntries.get(file);
|
||||||
|
if (lines > cap && debtCap === undefined) {
|
||||||
|
newDebt.push({ path: file, lines });
|
||||||
|
} else if (debtCap !== undefined && lines > debtCap) {
|
||||||
|
grownDebt.push({ path: file, lines, maxLines: debtCap });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = [];
|
||||||
|
const remaining = [];
|
||||||
|
for (const [debtPath, maxLines] of debtEntries) {
|
||||||
|
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
|
||||||
|
if (lines === -1 || lines <= cap) {
|
||||||
|
stale.push({ path: debtPath, lines });
|
||||||
|
} else {
|
||||||
|
remaining.push({ path: debtPath, lines, maxLines });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineLoosening = [];
|
||||||
|
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
|
||||||
|
if (baseBaseline) {
|
||||||
|
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
|
||||||
|
if (cap > baseCap) {
|
||||||
|
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
|
||||||
|
}
|
||||||
|
const baseEntries = new Map(
|
||||||
|
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||||
|
);
|
||||||
|
for (const [debtPath, maxLines] of debtEntries) {
|
||||||
|
const priorMax = baseEntries.get(debtPath);
|
||||||
|
if (priorMax === undefined) {
|
||||||
|
baselineLoosening.push(`new debt entry: ${debtPath}`);
|
||||||
|
} else if (maxLines > priorMax) {
|
||||||
|
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cap,
|
||||||
|
newDebt,
|
||||||
|
grownDebt,
|
||||||
|
stale,
|
||||||
|
remaining,
|
||||||
|
baselineLoosening,
|
||||||
|
comparedBaseline: Boolean(baseBaseline),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseRef() {
|
||||||
|
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
|
||||||
|
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||||
|
for (const candidate of ["origin/dev", "origin/main"]) {
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", candidate], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// candidate ref not present locally; try the next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedGovernedFiles(baseRef) {
|
||||||
|
let mergeBase;
|
||||||
|
try {
|
||||||
|
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
|
||||||
|
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||||
|
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maintainabilityGate(files) {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||||
|
}
|
||||||
|
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
|
||||||
|
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||||
|
const result = spawnSync(
|
||||||
|
eslintBin,
|
||||||
|
[
|
||||||
|
...files,
|
||||||
|
...ruleArgs,
|
||||||
|
"--max-warnings=0",
|
||||||
|
"--no-warn-ignored",
|
||||||
|
"--no-error-on-unmatched-pattern",
|
||||||
|
],
|
||||||
|
{ cwd: ROOT, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
skipped: false,
|
||||||
|
status: result.status,
|
||||||
|
stdout: result.stdout?.trim() ?? "",
|
||||||
|
stderr: result.stderr?.trim() ?? "",
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(count, word) {
|
||||||
|
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const failures = [];
|
||||||
|
const baseRef = resolveBaseRef();
|
||||||
|
if (!baseRef) {
|
||||||
|
failures.push(
|
||||||
|
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
gitText(["merge-base", baseRef, "HEAD"]);
|
||||||
|
} catch {
|
||||||
|
failures.push(
|
||||||
|
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("─".repeat(64));
|
||||||
|
console.log("godfile ratchet: legacy caps may only shrink");
|
||||||
|
const god = godfileRatchet(baseRef);
|
||||||
|
console.log(
|
||||||
|
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
|
||||||
|
);
|
||||||
|
for (const entry of god.remaining) {
|
||||||
|
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||||
|
}
|
||||||
|
for (const entry of god.newDebt) {
|
||||||
|
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
|
||||||
|
}
|
||||||
|
for (const entry of god.grownDebt) {
|
||||||
|
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||||
|
}
|
||||||
|
if (god.newDebt.length > 0) {
|
||||||
|
failures.push(
|
||||||
|
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (god.grownDebt.length > 0) {
|
||||||
|
failures.push(
|
||||||
|
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (god.baselineLoosening.length > 0) {
|
||||||
|
failures.push(
|
||||||
|
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!god.comparedBaseline) {
|
||||||
|
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
|
||||||
|
}
|
||||||
|
if (god.stale.length > 0) {
|
||||||
|
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
|
||||||
|
for (const entry of god.stale) {
|
||||||
|
console.log(` stale ${entry.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("─".repeat(64));
|
||||||
|
if (!baseRef) {
|
||||||
|
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
|
||||||
|
} else {
|
||||||
|
const files = changedGovernedFiles(baseRef);
|
||||||
|
console.log(
|
||||||
|
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
|
||||||
|
);
|
||||||
|
if (files === null) {
|
||||||
|
console.log(" failed — base ref could not be resolved against HEAD");
|
||||||
|
} else {
|
||||||
|
const gate = maintainabilityGate(files);
|
||||||
|
if (gate.skipped) {
|
||||||
|
console.log(` skipped — ${gate.reason}`);
|
||||||
|
} else {
|
||||||
|
const clean = gate.status === 0;
|
||||||
|
console.log(
|
||||||
|
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
|
||||||
|
);
|
||||||
|
if (!clean) {
|
||||||
|
if (gate.stdout) console.log(gate.stdout);
|
||||||
|
if (gate.stderr) console.log(gate.stderr);
|
||||||
|
failures.push(
|
||||||
|
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("─".repeat(64));
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||||
|
for (const failure of failures) console.log(` - ${failure}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("RESULT: PASS — all governance gates green");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -109,6 +109,8 @@ export const API_PATHS = {
|
||||||
update: "Vendor/Update",
|
update: "Vendor/Update",
|
||||||
delete: "Vendor/Delete",
|
delete: "Vendor/Delete",
|
||||||
dropdown: "Vendor/Dropdown",
|
dropdown: "Vendor/Dropdown",
|
||||||
|
facets: "Vendor/facets",
|
||||||
|
deactivationImpact: "deactivation-impact",
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { API_PATHS } from "@/api/api-paths";
|
||||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
import queryClient from "@/lib/query/query-client";
|
import queryClient from "@/lib/query/query-client";
|
||||||
|
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
||||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||||
|
|
||||||
function clearSessionAndRedirectToLogin(): void {
|
function clearSessionAndRedirectToLogin(): void {
|
||||||
|
|
@ -47,7 +48,7 @@ export const api: KyInstance = ky.create({
|
||||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||||
afterResponse: [
|
afterResponse: [
|
||||||
({ request, response }) => {
|
({ request, response }) => {
|
||||||
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
|
|
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
|
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(className)} {...props}>
|
<div className={cn(className)} {...props}>
|
||||||
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
|
<Text variant="title" className="m-0 text-center font-bold text-foreground">
|
||||||
{subtitle && (
|
{title}
|
||||||
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
|
</Text>
|
||||||
{subtitle}
|
<Text
|
||||||
</p>
|
variant="description"
|
||||||
)}
|
tone="muted"
|
||||||
|
when={Boolean(subtitle)}
|
||||||
|
className="mb-6! mt-1.5! text-center font-normal"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,9 @@ export function LoginForm({
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
error={Boolean(errors.password)}
|
error={Boolean(errors.password)}
|
||||||
/>
|
/>
|
||||||
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
|
{Boolean(loginError) && (
|
||||||
|
<FormErrorMessage message="Invalid email or password. Please try again." />
|
||||||
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
||||||
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||||
import { LoginForm } from "@/app/(auth)/_components/login-form";
|
import { LoginForm } from "@/app/(auth)/_components/login-form";
|
||||||
import { BrandLockup } from "@/components/common/brand-lockup";
|
import { BrandLockup } from "@/components/common/brand-lockup";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
|
|
@ -37,9 +38,13 @@ export default function LoginPage() {
|
||||||
isLoggingIn={isLoggingIn}
|
isLoggingIn={isLoggingIn}
|
||||||
loginError={loginError}
|
loginError={loginError}
|
||||||
/>
|
/>
|
||||||
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
tone="muted"
|
||||||
|
className="mt-5! block text-center text-(length:--text-xs)"
|
||||||
|
>
|
||||||
Having trouble? Contact your administrator.
|
Having trouble? Contact your administrator.
|
||||||
</p>
|
</Text>
|
||||||
</AuthPageShell>
|
</AuthPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export default function AccountFormPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load account"}
|
{error instanceof Error ? error.message : "Failed to load account"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export default function AccountsListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load accounts"}
|
{error instanceof Error ? error.message : "Failed to load accounts"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export default function AssetFormPage() {
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
|
||||||
|
|
||||||
{(error || optionsError) && (
|
{Boolean(error || optionsError) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error
|
{error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export default function AssetsListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load assets"}
|
{error instanceof Error ? error.message : "Failed to load assets"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export default function EventFormPage() {
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
|
||||||
|
|
||||||
{error && <Alert severity="error">Failed to load event</Alert>}
|
{Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export default function ContactFormPage() {
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load contact"}
|
{error instanceof Error ? error.message : "Failed to load contact"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export default function ContactsListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load contacts"}
|
{error instanceof Error ? error.message : "Failed to load contacts"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
||||||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{Boolean(description) && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -106,7 +106,7 @@ export default function DashboardPage() {
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ function SelectField({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{helperText && (
|
{Boolean(helperText) && (
|
||||||
<Typography variant="caption" color="error" className="mt-1">
|
<Typography variant="caption" color="error" className="mt-1">
|
||||||
{helperText}
|
{helperText}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load employee"}
|
{error instanceof Error ? error.message : "Failed to load employee"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export default function EmployeesListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load employees"}
|
{error instanceof Error ? error.message : "Failed to load employees"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load follow-up"}
|
{error instanceof Error ? error.message : "Failed to load follow-up"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
||||||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{Boolean(description) && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -106,7 +106,7 @@ export default function DashboardPage() {
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export default function LocationFormPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load location"}
|
{error instanceof Error ? error.message : "Failed to load location"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export default function LocationsListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load locations"}
|
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
104
src/app/(protected)/notifications/index.tsx
Normal file
104
src/app/(protected)/notifications/index.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>());
|
||||||
|
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
||||||
|
|
||||||
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
|
{Boolean(error) && <Alert severity="error">Failed to load PM schedule</Alert>}
|
||||||
{optionsError && (
|
{Boolean(optionsError) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
|
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load PM schedules"}
|
{error instanceof Error ? error.message : "Failed to load PM schedules"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{parentCategory && (
|
{Boolean(parentCategory) && (
|
||||||
<FormControl size="small" className="min-w-[160px]">
|
<FormControl size="small" className="min-w-[160px]">
|
||||||
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
|
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -201,7 +201,7 @@ export default function DropdownOptionsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load options"}
|
{error instanceof Error ? error.message : "Failed to load options"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
@ -262,7 +262,7 @@ export default function DropdownOptionsPage() {
|
||||||
<Typography variant="body2" className="truncate">
|
<Typography variant="body2" className="truncate">
|
||||||
{item.value}
|
{item.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
{item.parentValue && (
|
{Boolean(item.parentValue) && (
|
||||||
<Chip size="small" label={item.parentValue} variant="outlined" />
|
<Chip size="small" label={item.parentValue} variant="outlined" />
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export default function TaskTemplatesPage() {
|
||||||
Create reusable checklists for work order dispatches
|
Create reusable checklists for work order dispatches
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error" className="mb-4">
|
<Alert severity="error" className="mb-4">
|
||||||
{error instanceof Error ? error.message : "Failed to load templates"}
|
{error instanceof Error ? error.message : "Failed to load templates"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
|
||||||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
{row.workOrderId && (
|
{Boolean(row.workOrderId) && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export default function VendorPoViewPage() {
|
||||||
Dispatch {dispatch.dispatchNumber || "—"}
|
Dispatch {dispatch.dispatchNumber || "—"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip size="small" label={dispatch.status || "—"} />
|
<Chip size="small" label={dispatch.status || "—"} />
|
||||||
{vendor?.companyName && (
|
{vendor != null && Boolean(vendor.companyName) && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
· {vendor.companyName}
|
· {vendor.companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -177,7 +177,7 @@ export default function VendorPoViewPage() {
|
||||||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||||
</Box>
|
</Box>
|
||||||
{dispatch.description && (
|
{Boolean(dispatch.description) && (
|
||||||
<Box className="mt-3">
|
<Box className="mt-3">
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Description
|
Description
|
||||||
|
|
@ -189,7 +189,7 @@ export default function VendorPoViewPage() {
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{dispatch.primaryLocation && (
|
{dispatch.primaryLocation != null && (
|
||||||
<Paper variant="outlined" className="p-4">
|
<Paper variant="outlined" className="p-4">
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
Site
|
Site
|
||||||
|
|
@ -277,7 +277,7 @@ export default function VendorPoViewPage() {
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip size="small" label={u.status} />
|
<Chip size="small" label={u.status} />
|
||||||
</Stack>
|
</Stack>
|
||||||
{u.vendorReason && (
|
{Boolean(u.vendorReason) && (
|
||||||
<Typography variant="body2" className="mt-1">
|
<Typography variant="body2" className="mt-1">
|
||||||
<strong>Reason:</strong> {u.vendorReason}
|
<strong>Reason:</strong> {u.vendorReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export default function VendorPosListPage() {
|
||||||
Clear filters
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
155
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
155
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
|
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
interface VendorCreateModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues: VendorFormValues = {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||||
|
const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets();
|
||||||
|
const createVendor = useCreateVendor();
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VendorFormValues>({
|
||||||
|
resolver: zodResolver(vendorSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
reset(defaultValues);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
|
if (!company) return;
|
||||||
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
|
setValue("companyId", company.companyId, options);
|
||||||
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
|
setValue("email", company.email, options);
|
||||||
|
setValue("address", company.address, options);
|
||||||
|
setValue("city", company.city, options);
|
||||||
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
setSubmitError(null);
|
||||||
|
createVendor.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setSubmitError(error.message || "Failed to create vendor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const companies = facets?.companies ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={createVendor.isPending ? undefined : onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
aria-labelledby="vendor-create-dialog-title"
|
||||||
|
slotProps={{ paper: { role: "dialog" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle id="vendor-create-dialog-title">
|
||||||
|
Add Vendor
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
|
Register a technician and their vendor company.
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} className="mt-1">
|
||||||
|
{Boolean(facetsError) && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Company autocomplete unavailable. You can still type a company manually.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
{isLoadingFacets ? (
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<VendorFormFields
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={companies}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
onCompanySelected={onCompanySelected}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={createVendor.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={createVendor.isPending || !isFormValid}
|
||||||
|
>
|
||||||
|
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
486
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { useForm, useWatch, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Drawer,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
|
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
|
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text Message",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "—";
|
||||||
|
const first = parts[0]?.[0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||||
|
return {
|
||||||
|
companyName: vendor.companyName ?? "",
|
||||||
|
contactName: vendor.contactName ?? "",
|
||||||
|
email: vendor.email ?? "",
|
||||||
|
phone: vendor.phone ?? "",
|
||||||
|
companyPhone: vendor.companyPhone ?? "",
|
||||||
|
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
||||||
|
notes: vendor.notes ?? "",
|
||||||
|
totalJobs: vendor.totalJobs ?? 0,
|
||||||
|
address: vendor.address ?? "",
|
||||||
|
city: vendor.city ?? "",
|
||||||
|
state: vendor.state ?? "",
|
||||||
|
zip: vendor.zip ?? "",
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorDetailDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
vendor: VendorListItem | null;
|
||||||
|
mode: "view" | "edit";
|
||||||
|
onClose: () => void;
|
||||||
|
onModeChange: (mode: "view" | "edit") => void;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDetailDrawer({
|
||||||
|
open,
|
||||||
|
vendor,
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onModeChange,
|
||||||
|
tradeOptions,
|
||||||
|
}: VendorDetailDrawerProps) {
|
||||||
|
const vendorId = vendor?.id;
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
||||||
|
const updateVendor = useUpdateVendor();
|
||||||
|
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||||
|
|
||||||
|
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VendorFormValues>({
|
||||||
|
resolver: zodResolver(vendorSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && detail) {
|
||||||
|
const values = toFormValues(detail);
|
||||||
|
reset(values);
|
||||||
|
setOriginal(values);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setOriginal(null);
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
}, [open, detail, reset]);
|
||||||
|
|
||||||
|
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||||
|
if (!company) return;
|
||||||
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
|
setValue("companyId", company.companyId, options);
|
||||||
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
|
setValue("email", company.email, options);
|
||||||
|
setValue("address", company.address, options);
|
||||||
|
setValue("city", company.city, options);
|
||||||
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (original) {
|
||||||
|
reset(original);
|
||||||
|
}
|
||||||
|
setSubmitError(null);
|
||||||
|
onModeChange("view");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
if (vendorId === undefined || vendorId === null || vendorId === "") return;
|
||||||
|
setSubmitError(null);
|
||||||
|
updateVendor.mutate(
|
||||||
|
{ id: vendorId, values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onModeChange("view");
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setSubmitError(err.message || "Failed to save vendor");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
||||||
|
const mapsUrl = detail?.googleMapsUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={updateVendor.isPending ? undefined : onClose}
|
||||||
|
anchor="right"
|
||||||
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
|
ModalProps={{ keepMounted: false }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack className="flex h-full flex-col">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(vendor?.contactName ?? "")}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
id="vendor-detail-drawer-title"
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h2"
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{vendor?.companyName || "Vendor"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close drawer"
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
disabled={updateVendor.isPending}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : error ? (
|
||||||
|
<Alert severity="error" sx={{ m: 2 }}>
|
||||||
|
{error instanceof Error ? error.message : "Failed to load vendor"}
|
||||||
|
</Alert>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
{mode === "view" ? (
|
||||||
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||||
|
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={detail.isActive ? "Active" : "Inactive"}
|
||||||
|
color={detail.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
{Boolean(trades[0]) && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<DetailSection title="Technician">
|
||||||
|
<DetailField label="Technician Name" value={detail.contactName} />
|
||||||
|
<DetailField label="Technician Phone" value={detail.phone} />
|
||||||
|
<DetailField
|
||||||
|
label="Preferred Contact"
|
||||||
|
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Company">
|
||||||
|
<DetailField label="Company" value={detail.companyName} />
|
||||||
|
<DetailField label="Company Phone" value={detail.companyPhone} />
|
||||||
|
<DetailField label="Email" value={detail.email} />
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Location">
|
||||||
|
<DetailField
|
||||||
|
label="Address"
|
||||||
|
value={[detail.address, detail.city, detail.state, detail.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
/>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
|
<Link
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<LaunchIcon fontSize="small" />
|
||||||
|
Open in Google Maps
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Trades">
|
||||||
|
{trades.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No trades specified.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-wrap gap-1">
|
||||||
|
{trades.map((trade, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${idx}`}
|
||||||
|
size="small"
|
||||||
|
label={idx === 0 ? `${trade} (primary)` : trade}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Activity">
|
||||||
|
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
{Boolean(detail.notes) && (
|
||||||
|
<DetailSection title="Notes">
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{detail.notes}
|
||||||
|
</Typography>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
id="vendor-edit-form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-1 flex-col overflow-y-auto"
|
||||||
|
sx={{ px: 3, py: 3 }}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="isActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(field.value)}
|
||||||
|
onChange={field.onChange}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={field.value ? "Active" : "Inactive"}
|
||||||
|
sx={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VendorFormFields
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={companies}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
onCompanySelected={onCompanySelected}
|
||||||
|
showTotalJobs
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "view" ? (
|
||||||
|
<>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
|
<Tooltip title="Open in Google Maps">
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<LaunchIcon />}
|
||||||
|
>
|
||||||
|
Maps
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Box className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => onModeChange("edit")}
|
||||||
|
startIcon={<EditOutlinedIcon />}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={updateVendor.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
form="vendor-edit-form"
|
||||||
|
disabled={updateVendor.isPending || !isFormValid}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No vendor selected.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
className="font-semibold uppercase"
|
||||||
|
sx={{ color: "text.secondary", mb: 1 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>{children}</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailField({ label, value }: { label: string; value: string }) {
|
||||||
|
const display = value && value.trim() !== "" ? value : "—";
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{display}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||||
|
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type {
|
||||||
|
VendorFacets,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
|
|
||||||
|
interface VendorFiltersDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
facets: VendorFacets | undefined;
|
||||||
|
appliedFilters: VendorFilters;
|
||||||
|
onApply: (filters: VendorFilters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 360;
|
||||||
|
|
||||||
|
function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||||
|
{children}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorFiltersDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
facets,
|
||||||
|
appliedFilters,
|
||||||
|
onApply,
|
||||||
|
}: VendorFiltersDrawerProps) {
|
||||||
|
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(appliedFilters);
|
||||||
|
}
|
||||||
|
}, [open, appliedFilters]);
|
||||||
|
|
||||||
|
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
|
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
||||||
|
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
||||||
|
|
||||||
|
const activeCount =
|
||||||
|
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setDraft(emptyVendorFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(draft);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
anchor="right"
|
||||||
|
aria-labelledby="vendor-filters-drawer-title"
|
||||||
|
ModalProps={{ keepMounted: false }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack className="flex h-full flex-col">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||||
|
Filters
|
||||||
|
</Typography>
|
||||||
|
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
||||||
|
</Stack>
|
||||||
|
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||||
|
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Select vendor companies
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
companies:
|
||||||
|
current.companies.length === companyOptions.length
|
||||||
|
? []
|
||||||
|
: companyOptions.map((company) => company.name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||||
|
? "Clear all"
|
||||||
|
: "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={companyOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.name,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, companies: next }));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected }) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<li key={key} {...optionProps}>
|
||||||
|
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||||
|
{option.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.name;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={tradeOptions}
|
||||||
|
value={draft.trades}
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||||
|
}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={locationOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.label === value.label
|
||||||
|
}
|
||||||
|
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.label,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, locations: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={jobBucketOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.id === value.id
|
||||||
|
}
|
||||||
|
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.id,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface VendorFilters {
|
||||||
|
companies: string[];
|
||||||
|
trades: string[];
|
||||||
|
locations: string[];
|
||||||
|
jobBuckets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyVendorFilters: VendorFilters = {
|
||||||
|
companies: [],
|
||||||
|
trades: [],
|
||||||
|
locations: [],
|
||||||
|
jobBuckets: [],
|
||||||
|
};
|
||||||
420
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
420
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
FormLabel,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorFacetCompany,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
interface VendorFormFieldsProps {
|
||||||
|
control: Control<VendorFormValues>;
|
||||||
|
errors: FieldErrors<VendorFormValues>;
|
||||||
|
companies: VendorFacetCompany[];
|
||||||
|
tradeOptions: string[];
|
||||||
|
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
||||||
|
showTotalJobs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinTrades(trades: string[]): string {
|
||||||
|
return trades.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhoneMask(input: string): string {
|
||||||
|
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||||
|
if (digits.length === 0) return "";
|
||||||
|
if (digits.length < 4) return `(${digits}`;
|
||||||
|
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorFormFields({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
companies,
|
||||||
|
tradeOptions,
|
||||||
|
onCompanySelected,
|
||||||
|
showTotalJobs = false,
|
||||||
|
}: VendorFormFieldsProps) {
|
||||||
|
const [tradeInput, setTradeInput] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Technician
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="contactName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.contactName)}
|
||||||
|
helperText={errors.contactName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.phone)}
|
||||||
|
helperText={errors.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 0.5 }}>
|
||||||
|
Preferred Contact
|
||||||
|
</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="preferredContact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={field.value ?? "Phone"}
|
||||||
|
onChange={(_event, value: VendorPreferredContact | null) => {
|
||||||
|
if (value) field.onChange(value);
|
||||||
|
}}
|
||||||
|
aria-label="Preferred contact"
|
||||||
|
>
|
||||||
|
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
|
||||||
|
<ToggleButton key={option} value={option} aria-label={option}>
|
||||||
|
{PREFERRED_CONTACT_LABELS[option]}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Company
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyId"
|
||||||
|
render={({ field: companyIdField }) => (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={companies}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
inputValue={field.value ?? ""}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
field.onChange(value);
|
||||||
|
companyIdField.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(_event, value, reason) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
field.onChange(value);
|
||||||
|
companyIdField.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
field.onChange(value.name);
|
||||||
|
companyIdField.onChange(value.companyId);
|
||||||
|
if (reason === "selectOption") {
|
||||||
|
onCompanySelected(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange("");
|
||||||
|
companyIdField.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Company"
|
||||||
|
required
|
||||||
|
placeholder="Select or type a company"
|
||||||
|
error={Boolean(errors.companyName)}
|
||||||
|
helperText={errors.companyName?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyPhone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.companyPhone)}
|
||||||
|
helperText={errors.companyPhone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
error={Boolean(errors.email)}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="address"
|
||||||
|
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="city"
|
||||||
|
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="state"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="zip"
|
||||||
|
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="googleMapsUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Google Maps URL"
|
||||||
|
placeholder="https://maps.google.com/..."
|
||||||
|
error={Boolean(errors.googleMapsUrl)}
|
||||||
|
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
First trade is primary. Reorder with the arrows.
|
||||||
|
</Typography>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tradeSpecialties"
|
||||||
|
render={({ field }) => {
|
||||||
|
const trades = splitTrades(field.value);
|
||||||
|
const remove = (trade: string) => {
|
||||||
|
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
|
||||||
|
};
|
||||||
|
const move = (index: number, direction: -1 | 1) => {
|
||||||
|
const next = [...trades];
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= next.length) return;
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
field.onChange(joinTrades(next));
|
||||||
|
};
|
||||||
|
const add = (trade: string) => {
|
||||||
|
const normalized = trade.trim();
|
||||||
|
if (!normalized || trades.includes(normalized)) {
|
||||||
|
setTradeInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.onChange(joinTrades([...trades, normalized]));
|
||||||
|
setTradeInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mt-2">
|
||||||
|
<Box className="flex flex-wrap gap-1.5">
|
||||||
|
{trades.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No trades selected.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
trades.map((trade, index) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${index}`}
|
||||||
|
label={index === 0 ? `${trade} (primary)` : trade}
|
||||||
|
onDelete={() => remove(trade)}
|
||||||
|
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||||
|
sx={{ px: 0.5 }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={tradeOptions}
|
||||||
|
value={null}
|
||||||
|
inputValue={tradeInput}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") setTradeInput(value);
|
||||||
|
}}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (typeof value === "string") add(value);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Add Trade"
|
||||||
|
placeholder="Type or select a trade"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
add(tradeInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={() => add(tradeInput)}
|
||||||
|
aria-label="Add trade"
|
||||||
|
disabled={!tradeInput.trim()}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{trades.length > 1 && (
|
||||||
|
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
|
||||||
|
{trades.map((trade, index) => (
|
||||||
|
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} up`}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => move(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-label={`Move ${trade} down`}
|
||||||
|
disabled={index === trades.length - 1}
|
||||||
|
onClick={() => move(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{showTotalJobs && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="totalJobs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? 0}
|
||||||
|
label="Total Jobs"
|
||||||
|
type="number"
|
||||||
|
slotProps={{ input: { readOnly: true } }}
|
||||||
|
helperText="Calculated from work-order history"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField {...field} label="Notes" multiline minRows={3} fullWidth />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,34 +6,40 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
FormGroup,
|
|
||||||
FormLabel,
|
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const defaultValues: VendorFormValues = {
|
const defaultValues: VendorFormValues = {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
contactName: "",
|
contactName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
address: "",
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VendorFormPage() {
|
export default function VendorFormPage() {
|
||||||
|
|
@ -42,21 +48,25 @@ export default function VendorFormPage() {
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
const createVendor = useCreateVendor();
|
const createVendor = useCreateVendor();
|
||||||
const updateVendor = useUpdateVendor();
|
const updateVendor = useUpdateVendor();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (vendor && isEdit) {
|
if (vendor && isEdit) {
|
||||||
|
|
@ -65,37 +75,44 @@ export default function VendorFormPage() {
|
||||||
contactName: vendor.contactName,
|
contactName: vendor.contactName,
|
||||||
email: vendor.email,
|
email: vendor.email,
|
||||||
phone: vendor.phone,
|
phone: vendor.phone,
|
||||||
|
companyPhone: vendor.companyPhone,
|
||||||
|
googleMapsUrl: vendor.googleMapsUrl,
|
||||||
|
notes: vendor.notes,
|
||||||
|
totalJobs: vendor.totalJobs,
|
||||||
address: vendor.address,
|
address: vendor.address,
|
||||||
city: vendor.city,
|
city: vendor.city,
|
||||||
state: vendor.state,
|
state: vendor.state,
|
||||||
zip: vendor.zip,
|
zip: vendor.zip,
|
||||||
tradeSpecialties: vendor.tradeSpecialties,
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
isActive: vendor.isActive,
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [vendor, isEdit, reset]);
|
}, [vendor, isEdit, reset]);
|
||||||
|
|
||||||
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
const selectedTrades = (tradeSpecialties || "")
|
if (!company) return;
|
||||||
.split(",")
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
.map((trade) => trade.trim())
|
setValue("companyId", company.companyId, options);
|
||||||
.filter(Boolean);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
|
setValue("email", company.email, options);
|
||||||
const toggleTrade = (tradeValue: string) => {
|
setValue("address", company.address, options);
|
||||||
const updated = selectedTrades.includes(tradeValue)
|
setValue("city", company.city, options);
|
||||||
? selectedTrades.filter((trade) => trade !== tradeValue)
|
setValue("state", company.state, options);
|
||||||
: [...selectedTrades, tradeValue];
|
setValue("zip", company.zip, options);
|
||||||
setValue("tradeSpecialties", updated.join(", "));
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
if (isEdit && id) {
|
if (isEdit && id) {
|
||||||
updateVendor.mutate({ id, values });
|
updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createVendor.mutate(values);
|
createVendor.mutate(values, { onSuccess: () => navigate("/vendors") });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
if (isEdit && isLoading) {
|
||||||
|
|
@ -112,7 +129,7 @@ export default function VendorFormPage() {
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
|
||||||
|
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load vendor"}
|
{error instanceof Error ? error.message : "Failed to load vendor"}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
@ -120,73 +137,27 @@ export default function VendorFormPage() {
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
General Details
|
|
||||||
</Typography>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Company Name"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(errors.companyName)}
|
|
||||||
helperText={errors.companyName?.message}
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
<TextField label="Contact Name" fullWidth {...register("contactName")} />
|
|
||||||
</Stack>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
type="email"
|
|
||||||
error={Boolean(errors.email)}
|
|
||||||
helperText={errors.email?.message}
|
|
||||||
{...register("email")}
|
|
||||||
/>
|
|
||||||
<TextField label="Phone" fullWidth {...register("phone")} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
||||||
Address
|
|
||||||
</Typography>
|
|
||||||
<TextField label="Address" fullWidth {...register("address")} />
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
<TextField label="City" fullWidth {...register("city")} />
|
|
||||||
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
|
|
||||||
<TextField label="Zip" fullWidth {...register("zip")} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
|
||||||
<Box className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{trades.map((trade) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={String(trade.id)}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedTrades.includes(trade.value)}
|
|
||||||
onChange={() => toggleTrade(trade.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={trade.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="isActive"
|
name="isActive"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox checked={field.value} onChange={field.onChange} />}
|
control={<Switch checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||||
label="Active"
|
label={field.value ? "Active" : "Inactive"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
|
<VendorFormFields
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={facets?.companies ?? []}
|
||||||
|
tradeOptions={trades.map((trade) => trade.value)}
|
||||||
|
onCompanySelected={onCompanySelected}
|
||||||
|
showTotalJobs={isEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEdit && id != null && <VendorPortalTokenPanel vendorId={id} />}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
|
|
@ -198,7 +169,7 @@ export default function VendorFormPage() {
|
||||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
598
src/app/(protected)/vendors/index.tsx
vendored
598
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,9 +1,11 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
|
@ -14,8 +16,12 @@ import {
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tab,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
|
@ -23,39 +29,193 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TablePagination,
|
TablePagination,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { VendorCreateModal } from "./_components/vendor-create-modal";
|
||||||
|
import { VendorDetailDrawer } from "./_components/vendor-detail-drawer";
|
||||||
|
import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
||||||
|
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||||
|
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
const TABLE_COLUMNS = [
|
||||||
|
"Company",
|
||||||
|
"Technician",
|
||||||
|
"Location",
|
||||||
|
"Trade",
|
||||||
|
"Phone",
|
||||||
|
"Email",
|
||||||
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
|
"View",
|
||||||
|
"Edit",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
const first = parts[0]?.[0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTrades(value: string | undefined): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppliedChip {
|
||||||
|
category: keyof VendorFilters;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeChips(
|
||||||
|
filters: VendorFilters,
|
||||||
|
jobBucketLabels: Map<string, string>,
|
||||||
|
): AppliedChip[] {
|
||||||
|
const chips: AppliedChip[] = [];
|
||||||
|
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
|
||||||
|
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
|
||||||
|
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
|
||||||
|
filters.jobBuckets.forEach((value) =>
|
||||||
|
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
||||||
|
);
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
export default function VendorsListPage() {
|
export default function VendorsListPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
const {
|
||||||
|
data: deactivationImpact,
|
||||||
|
isLoading: isLoadingImpact,
|
||||||
|
error: impactError,
|
||||||
|
} = useVendorDeactivationImpact(deleteTarget?.id);
|
||||||
|
const { data: facets } = useVendorFacets(isActive);
|
||||||
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
|
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
|
() => ({
|
||||||
[debouncedSearch, page, pageSize],
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
isActive,
|
||||||
|
companies: appliedFilters.companies,
|
||||||
|
trades: appliedFilters.trades,
|
||||||
|
locations: appliedFilters.locations,
|
||||||
|
jobBuckets: appliedFilters.jobBuckets,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize, isActive, appliedFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
|
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
|
||||||
|
const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
|
||||||
|
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
const activeCount = activeCountData?.totalCount ?? 0;
|
||||||
|
const inactiveCount = inactiveCountData?.totalCount ?? 0;
|
||||||
|
const globalCount = activeCount + inactiveCount;
|
||||||
|
|
||||||
|
const jobBucketLabels = useMemo(
|
||||||
|
() => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])),
|
||||||
|
[facets],
|
||||||
|
);
|
||||||
|
const appliedChips = useMemo(
|
||||||
|
() => describeChips(appliedFilters, jobBucketLabels),
|
||||||
|
[appliedFilters, jobBucketLabels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApplyFilters = (next: VendorFilters) => {
|
||||||
|
setAppliedFilters(next);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setAppliedFilters(emptyVendorFilters);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveChip = (chip: AppliedChip) => {
|
||||||
|
setAppliedFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
[chip.category]: current[chip.category].filter((value) => value !== chip.value),
|
||||||
|
}));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = (row: VendorListItem, mode: "view" | "edit") => {
|
||||||
|
setDetailTarget(row);
|
||||||
|
setDetailMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
setDetailTarget(null);
|
||||||
|
setDetailMode("view");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeactivation = (row: VendorListItem) => {
|
||||||
|
setDeleteTarget(row);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeactivation = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
setDeleteError(null);
|
||||||
|
deleteVendor.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setDeleteError(error.message || "Failed to deactivate vendor");
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopAnd = (handler: () => void) => (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(row, "view");
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLoadingRow = isLoading;
|
||||||
|
const showEmptyRow = !isLoading && !error && rows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -66,23 +226,12 @@ export default function VendorsListPage() {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
Vendors
|
Vendors / Technicians
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
variant="body2"
|
{globalCount} technicians · {activeCount} active
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
@ -90,9 +239,7 @@ export default function VendorsListPage() {
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: "column", sm: "row" }}
|
direction={{ xs: "column", sm: "row" }}
|
||||||
spacing={2}
|
spacing={2}
|
||||||
sx={{
|
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -101,133 +248,362 @@ export default function VendorsListPage() {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search vendors..."
|
placeholder="Search name, company, trade..."
|
||||||
className="min-w-sidebar"
|
className="min-w-[260px]"
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
|
<Button
|
||||||
+ New Vendor
|
variant="outlined"
|
||||||
|
onClick={() => setFiltersOpen(true)}
|
||||||
|
startIcon={<FilterListIcon />}
|
||||||
|
aria-label="Open vendor filters"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{appliedChips.length > 0 && (
|
||||||
|
<Chip size="small" color="primary" label={appliedChips.length} sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={() => setCreateOpen(true)}>
|
||||||
|
+ Add Vendor
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{error && (
|
|
||||||
|
<Tabs
|
||||||
|
value={isActive ? "active" : "inactive"}
|
||||||
|
onChange={(_event, value: "active" | "inactive") => {
|
||||||
|
setIsActive(value === "active");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
aria-label="Vendor status"
|
||||||
|
>
|
||||||
|
<Tab value="active" label={`Active (${activeCount})`} />
|
||||||
|
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{appliedChips.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Applied:
|
||||||
|
</Typography>
|
||||||
|
{appliedChips.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={`${chip.category}-${chip.value}`}
|
||||||
|
size="small"
|
||||||
|
label={chip.label}
|
||||||
|
onDelete={() => handleRemoveChip(chip)}
|
||||||
|
aria-label={`Remove filter ${chip.label}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button size="small" onClick={handleClearFilters}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load vendors"}
|
{error instanceof Error ? error.message : "Failed to load vendors"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined">
|
|
||||||
<Table size="small">
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 960 }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Company</TableCell>
|
{TABLE_COLUMNS.map((column) => (
|
||||||
<TableCell className="font-semibold">Contact</TableCell>
|
<TableCell
|
||||||
<TableCell className="font-semibold">Email</TableCell>
|
key={column}
|
||||||
<TableCell className="font-semibold">Phone</TableCell>
|
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||||
<TableCell className="font-semibold">Trades</TableCell>
|
className="font-semibold"
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
>
|
||||||
<TableCell align="right" className="font-semibold">
|
{column}
|
||||||
Actions
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{showLoadingRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : showEmptyRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<Typography
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
sx={{
|
No vendors match the current filters.
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No vendors found
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => {
|
||||||
<TableRow key={String(row.id)} hover>
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
<TableCell>
|
const primaryTrade = trades[0] ?? "—";
|
||||||
<Button
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
variant="text"
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
size="small"
|
return (
|
||||||
className="font-semibold"
|
<TableRow
|
||||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
key={String(row.id)}
|
||||||
>
|
hover
|
||||||
{row.companyName || "—"}
|
tabIndex={0}
|
||||||
</Button>
|
role="button"
|
||||||
</TableCell>
|
aria-label={`Open vendor details for ${row.companyName}`}
|
||||||
<TableCell>{row.contactName || "—"}</TableCell>
|
sx={{ cursor: "pointer" }}
|
||||||
<TableCell>{row.email || "—"}</TableCell>
|
onClick={() => openDetail(row, "view")}
|
||||||
<TableCell>{row.phone || "—"}</TableCell>
|
onKeyDown={handleRowKeyDown(row)}
|
||||||
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
size="small"
|
</TableCell>
|
||||||
label={row.isActive ? "Active" : "Inactive"}
|
<TableCell>
|
||||||
color={row.isActive ? "success" : "default"}
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
/>
|
<Avatar
|
||||||
</TableCell>
|
sx={{
|
||||||
<TableCell align="right">
|
width: 28,
|
||||||
<Tooltip title="Edit">
|
height: 28,
|
||||||
<IconButton
|
fontSize: 12,
|
||||||
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(row.contactName)}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{locationText}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{primaryTrade === "—" ? (
|
||||||
|
"—"
|
||||||
|
) : (
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||||
|
<span>{primaryTrade}</span>
|
||||||
|
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.phone ? (
|
||||||
|
<Link
|
||||||
|
href={`tel:${row.phone}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.phone}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.email ? (
|
||||||
|
<Link
|
||||||
|
href={`mailto:${row.email}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.email}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Edit vendor"
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
color={row.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Tooltip title="View details">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
|
onClick={stopAnd(() => openDetail(row, "view"))}
|
||||||
|
>
|
||||||
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.5}
|
||||||
|
sx={{ alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<Tooltip title="Edit">
|
||||||
</IconButton>
|
<IconButton
|
||||||
</Tooltip>
|
size="small"
|
||||||
<Tooltip title="Delete">
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
<IconButton
|
onClick={stopAnd(() => openDetail(row, "edit"))}
|
||||||
size="small"
|
>
|
||||||
aria-label="Delete vendor"
|
<EditOutlinedIcon fontSize="small" />
|
||||||
onClick={() => setDeleteTarget(row)}
|
</IconButton>
|
||||||
>
|
</Tooltip>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
{row.isActive && (
|
||||||
</IconButton>
|
<Tooltip title="Deactivate">
|
||||||
</Tooltip>
|
<IconButton
|
||||||
</TableCell>
|
size="small"
|
||||||
</TableRow>
|
aria-label="Deactivate vendor"
|
||||||
))
|
onClick={stopAnd(() => openDeactivation(row))}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<TablePagination
|
|
||||||
component="div"
|
<Stack
|
||||||
count={totalCount}
|
direction={{ xs: "column", sm: "row" }}
|
||||||
page={page}
|
spacing={2}
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
sx={{
|
||||||
rowsPerPage={pageSize}
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
onRowsPerPageChange={(e) => {
|
justifyContent: "space-between",
|
||||||
setPageSize(Number(e.target.value));
|
|
||||||
setPage(0);
|
|
||||||
}}
|
}}
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||||
|
{totalCount === 1 ? "technician" : "technicians"}
|
||||||
|
{(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
|
||||||
|
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
||||||
|
</Typography>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<VendorCreateModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
/>
|
/>
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
|
||||||
<DialogTitle>Delete Vendor</DialogTitle>
|
<VendorFiltersDrawer
|
||||||
|
open={filtersOpen}
|
||||||
|
onClose={() => setFiltersOpen(false)}
|
||||||
|
facets={facets}
|
||||||
|
appliedFilters={appliedFilters}
|
||||||
|
onApply={handleApplyFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VendorDetailDrawer
|
||||||
|
open={Boolean(detailTarget)}
|
||||||
|
vendor={detailTarget}
|
||||||
|
mode={detailMode}
|
||||||
|
onClose={closeDetail}
|
||||||
|
onModeChange={setDetailMode}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
|
||||||
|
>
|
||||||
|
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<Stack spacing={2}>
|
||||||
Are you sure you want to delete "{deleteTarget?.companyName}"?
|
<DialogContentText>
|
||||||
</DialogContentText>
|
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||||
|
history will be preserved.
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
{isLoadingImpact && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Checking linked work orders...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(impactError) && !isLoadingImpact && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Could not verify linked work orders. Close and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact != null &&
|
||||||
|
!isLoadingImpact &&
|
||||||
|
!deactivationImpact.canDeactivate && (
|
||||||
|
<Alert severity="error">
|
||||||
|
This vendor cannot be deactivated because it still has open work orders.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Open work orders ({deactivationImpact.openWorkOrders.length})
|
||||||
|
</Typography>
|
||||||
|
<List dense sx={{ mt: 0.5 }}>
|
||||||
|
{deactivationImpact.openWorkOrders.map((wo) => (
|
||||||
|
<ListItem
|
||||||
|
key={String(wo.workOrderId)}
|
||||||
|
sx={{ px: 0, py: 0.25 }}
|
||||||
|
secondaryAction={
|
||||||
|
wo.scheduledDate ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.scheduledDate}
|
||||||
|
</Typography>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{wo.workOrderNumber
|
||||||
|
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||||
|
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(wo.status) && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.status}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
|
||||||
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="error"
|
color="error"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteVendor.isPending}
|
disabled={
|
||||||
|
deleteVendor.isPending ||
|
||||||
|
isLoadingImpact ||
|
||||||
|
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
||||||
|
Boolean(impactError)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{deleteVendor.isPending ? "Deleting..." : "Delete"}
|
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
|
||||||
|
function percent(value: number) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorInsightsPage() {
|
||||||
|
const [from, setFrom] = useState("");
|
||||||
|
const [to, setTo] = useState("");
|
||||||
|
const [trade, setTrade] = useState("");
|
||||||
|
const params = { from: from || undefined, to: to || undefined, trade: trade || undefined };
|
||||||
|
const {
|
||||||
|
data = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["vendor-operations", "insights", params],
|
||||||
|
queryFn: () => vendorOperationsApi.insights(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Vendor performance insights
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Metrics include inactive vendors for historical reporting and use the same filtered
|
||||||
|
dispatch population.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="From"
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
onChange={(event) => setFrom(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="To"
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
onChange={(event) => setTo(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Trade"
|
||||||
|
value={trade}
|
||||||
|
onChange={(event) => setTrade(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => void vendorOperationsApi.downloadInsights("csv", params)}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => void vendorOperationsApi.downloadInsights("pdf", params)}
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
{isLoading && <CircularProgress aria-label="Loading vendor insights" />}
|
||||||
|
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||||
|
{!isLoading && !error && data.length === 0 && (
|
||||||
|
<Alert severity="info">No data matches the selected filters.</Alert>
|
||||||
|
)}
|
||||||
|
{data.length > 0 && (
|
||||||
|
<Paper variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
|
<Table aria-label="Vendor performance metrics">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell align="right">Completed</TableCell>
|
||||||
|
<TableCell align="right">Cancellation / refusal</TableCell>
|
||||||
|
<TableCell align="right">On-time arrival</TableCell>
|
||||||
|
<TableCell align="right">On-time completion</TableCell>
|
||||||
|
<TableCell align="right">Avg. cycle</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<TableRow key={row.vendorId}>
|
||||||
|
<TableCell>{row.vendor}</TableCell>
|
||||||
|
<TableCell align="right">{row.completedJobs}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.cancellationRefusalRate)}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.onTimeArrivalRate)}</TableCell>
|
||||||
|
<TableCell align="right">{percent(row.onTimeCompletionRate)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{row.averageCycleHours == null
|
||||||
|
? "Insufficient data"
|
||||||
|
: `${Number(row.averageCycleHours).toFixed(1)}h`}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
|
||||||
|
|
||||||
|
type AddVendorPreferenceControlsProps = {
|
||||||
|
trade: string;
|
||||||
|
onTradeChange: (value: string) => void;
|
||||||
|
vendorOptions: VendorDropdownItem[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
vendorId: number | null;
|
||||||
|
onVendorChange: (vendorId: number | null) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AddVendorPreferenceControls({
|
||||||
|
trade,
|
||||||
|
onTradeChange,
|
||||||
|
vendorOptions,
|
||||||
|
vendors,
|
||||||
|
vendorId,
|
||||||
|
onVendorChange,
|
||||||
|
onAdd,
|
||||||
|
}: AddVendorPreferenceControlsProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Trade scope (optional)"
|
||||||
|
value={trade}
|
||||||
|
onChange={(event) => onTradeChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={vendorOptions}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
|
||||||
|
}
|
||||||
|
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
|
||||||
|
onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={onAdd} disabled={vendorId == null}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreferenceRowProps = {
|
||||||
|
item: SiteVendorPreference;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onMove: (index: number, offset: number) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-3">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Text className="flex-1">
|
||||||
|
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
|
||||||
|
</Text>
|
||||||
|
{item.isStale && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Text as="span">Stale preference</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} up`}
|
||||||
|
onClick={() => onMove(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} down`}
|
||||||
|
onClick={() => onMove(index, 1)}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label={`Remove ${item.vendorName}`} onClick={() => onRemove(index)}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorPreferencesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [locationId, setLocationId] = useState<string | number>("");
|
||||||
|
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [trade, setTrade] = useState("");
|
||||||
|
const [vendorId, setVendorId] = useState<number | null>(null);
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
|
||||||
|
const queryKey = ["vendor-operations", "site-preferences", locationId];
|
||||||
|
const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
|
||||||
|
enabled: locationId !== "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A background refetch must not clobber in-flight edits, so only adopt server
|
||||||
|
// data while the draft is clean (initial load, site change, or after a save).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) {
|
||||||
|
setDraft(serverPreferences);
|
||||||
|
}
|
||||||
|
}, [isDirty, serverPreferences]);
|
||||||
|
|
||||||
|
const vendorOptions = useMemo(() => {
|
||||||
|
const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId));
|
||||||
|
return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id)));
|
||||||
|
}, [draft, vendors]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
vendorOperationsApi.replaceSitePreferences(
|
||||||
|
locationId,
|
||||||
|
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
|
||||||
|
),
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
},
|
||||||
|
onSuccess: (savedPreferences) => {
|
||||||
|
queryClient.setQueryData(queryKey, savedPreferences);
|
||||||
|
setDraft(savedPreferences);
|
||||||
|
setIsDirty(false);
|
||||||
|
toast.success("Preferred vendors updated");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const move = (index: number, offset: number) => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const target = index + offset;
|
||||||
|
if (target < 0 || target >= current.length) return current;
|
||||||
|
const next = [...current];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const vendor = vendors.find((item) => Number(item.id) === vendorId);
|
||||||
|
if (!vendor || locationId === "") return;
|
||||||
|
setDraft((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: -Date.now(),
|
||||||
|
locationId: Number(locationId),
|
||||||
|
vendorId: Number(vendor.id),
|
||||||
|
vendorName: vendor.companyName,
|
||||||
|
trade: trade.trim() || undefined,
|
||||||
|
sortOrder: current.length,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setVendorId(null);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (index: number) => {
|
||||||
|
setDraft((current) => current.filter((_, row) => row !== index));
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLocation = (nextLocationId: string | number) => {
|
||||||
|
setLocationId(nextLocationId);
|
||||||
|
setDraft(EMPTY_PREFERENCES);
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Box>
|
||||||
|
<Text variant="title" as="h1">
|
||||||
|
Preferred vendors by site
|
||||||
|
</Text>
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
Preferred vendors rank first, but availability, conflict, trade, active-state, and
|
||||||
|
permission checks still apply.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Autocomplete
|
||||||
|
options={locations}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
value={locations.find((location) => location.id === locationId) ?? null}
|
||||||
|
onChange={(_event, option) => selectLocation(option?.id ?? "")}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Site" />}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
<Text variant="error" when={Boolean(error)}>
|
||||||
|
Unable to load preferred vendors. Please try again.
|
||||||
|
</Text>
|
||||||
|
{locationId !== "" && (
|
||||||
|
<>
|
||||||
|
<AddVendorPreferenceControls
|
||||||
|
trade={trade}
|
||||||
|
onTradeChange={setTrade}
|
||||||
|
vendorOptions={vendorOptions}
|
||||||
|
vendors={vendors}
|
||||||
|
vendorId={vendorId}
|
||||||
|
onVendorChange={setVendorId}
|
||||||
|
onAdd={add}
|
||||||
|
/>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{draft.map((item, index) => (
|
||||||
|
<PreferenceRow
|
||||||
|
key={`${item.vendorId}-${item.trade ?? "all"}`}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
total={draft.length}
|
||||||
|
onMove={move}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
{save.isPending ? "Saving…" : "Save preferences"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,41 +1,308 @@
|
||||||
import { Link as RouterLink, useNavigate } from "react-router";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Link,
|
Link,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState } from "react";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||||
import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections";
|
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||||
import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu";
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import {
|
import {
|
||||||
useAddWorkOrderComment,
|
useAddWorkOrderComment,
|
||||||
useChangeWorkOrderAssignment,
|
useChangeWorkOrderAssignment,
|
||||||
useChangeWorkOrderStatus,
|
useChangeWorkOrderStatus,
|
||||||
|
useUpdateWorkOrderVendor,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||||
|
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
string,
|
||||||
|
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||||
|
> = {
|
||||||
|
Open: "primary",
|
||||||
|
"In Progress": "info",
|
||||||
|
"On Hold": "warning",
|
||||||
|
Done: "success",
|
||||||
|
Cancelled: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
interface VendorEditorState {
|
||||||
|
vendorId: string | number;
|
||||||
|
vendorName: string;
|
||||||
|
contactName: string;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhoneMask(input: string): string {
|
||||||
|
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||||
|
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
|
||||||
|
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
|
||||||
|
return (
|
||||||
|
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...comments].sort(
|
||||||
|
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
|
||||||
|
),
|
||||||
|
[comments],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No comments yet.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||||
|
{sorted.map((comment) => (
|
||||||
|
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
className="mb-1"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{comment.userName || comment.commenter || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...entries].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No changes recorded.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||||
|
{sorted.map((entry) => (
|
||||||
|
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
className="mb-1"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{entry.userName || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkOrderViewPage() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
|
|
||||||
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
const { data: users = [] } = useWorkOrderUsers();
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||||
|
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||||
|
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
|
||||||
|
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
|
||||||
|
const [vendorEditorError, setVendorEditorError] = useState("");
|
||||||
|
|
||||||
|
const customerComments = useMemo(
|
||||||
|
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||||
|
[wo?.comments],
|
||||||
|
);
|
||||||
|
const internalComments = useMemo(
|
||||||
|
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||||
|
[wo?.comments],
|
||||||
|
);
|
||||||
|
const auditLog = wo?.auditLog ?? [];
|
||||||
|
|
||||||
|
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentMutation.mutate(
|
||||||
|
{ text: commentText.trim(), commentType: activeTab },
|
||||||
|
{ onSuccess: () => setCommentText("") },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVendorEditor = async (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
vendorId: string | number,
|
||||||
|
vendorName: string,
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setVendorEditorError("");
|
||||||
|
setVendorEditorLoading(true);
|
||||||
|
setVendorEditor({
|
||||||
|
vendorId,
|
||||||
|
vendorName,
|
||||||
|
contactName: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vendor = await vendorsApi.getById(vendorId);
|
||||||
|
setVendorEditor({
|
||||||
|
vendorId,
|
||||||
|
vendorName: vendor.companyName || vendorName,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
preferredContact: vendor.preferredContact,
|
||||||
|
phone: vendor.phone,
|
||||||
|
email: vendor.email,
|
||||||
|
notes: vendor.notes,
|
||||||
|
});
|
||||||
|
} catch (vendorError) {
|
||||||
|
setVendorEditorError(
|
||||||
|
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setVendorEditorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveVendorEditor = () => {
|
||||||
|
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||||
|
const numericWorkOrderId = Number(workOrderId);
|
||||||
|
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||||
|
setVendorEditorError("A valid work order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vendorMutation.mutate(
|
||||||
|
{
|
||||||
|
vendorId: vendorEditor.vendorId,
|
||||||
|
payload: {
|
||||||
|
workOrderId: numericWorkOrderId,
|
||||||
|
contactName: vendorEditor.contactName.trim(),
|
||||||
|
preferredContact: vendorEditor.preferredContact,
|
||||||
|
phone: vendorEditor.phone,
|
||||||
|
email: vendorEditor.email.trim(),
|
||||||
|
notes: vendorEditor.notes.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setVendorEditor(null);
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
onError: (vendorError) => setVendorEditorError(vendorError.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,9 +328,14 @@ export default function WorkOrderViewPage() {
|
||||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||||
Work Orders
|
Work Orders
|
||||||
</Link>
|
</Link>
|
||||||
<Typography color="text.primary">{wo.internalWONumber}</Typography>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wo.internalWONumber}
|
||||||
|
</Typography>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
spacing={2}
|
spacing={2}
|
||||||
|
|
@ -76,13 +348,41 @@ export default function WorkOrderViewPage() {
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
{wo.title}
|
{wo.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<WorkOrderStatusMenu
|
<Chip
|
||||||
status={wo.status}
|
label={wo.status}
|
||||||
onStatusChange={(status) => statusMutation.mutate(status)}
|
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||||
|
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
||||||
|
className="w-fit cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={statusAnchor}
|
||||||
|
open={Boolean(statusAnchor)}
|
||||||
|
onClose={() => setStatusAnchor(null)}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem
|
||||||
|
key={status}
|
||||||
|
selected={status === wo.status}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusAnchor(null);
|
||||||
|
if (status !== wo.status) {
|
||||||
|
statusMutation.mutate(status);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
||||||
Dispatch
|
Dispatch
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -94,27 +394,404 @@ export default function WorkOrderViewPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Details
|
||||||
|
</Typography>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Field label="Internal WO" value={wo.internalWONumber} />
|
||||||
|
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||||
|
<Field label="Priority" value={wo.priority || "—"} />
|
||||||
|
<Field label="Location" value={wo.location || "—"} />
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Assigned To
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||||
|
{wo.assignedTo || "Unassigned"}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={assignAnchor}
|
||||||
|
open={Boolean(assignAnchor)}
|
||||||
|
onClose={() => setAssignAnchor(null)}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setAssignAnchor(null);
|
||||||
|
assignmentMutation.mutate({ userId: "", userName: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unassigned
|
||||||
|
</MenuItem>
|
||||||
|
{users.map((user) => (
|
||||||
|
<MenuItem
|
||||||
|
key={String(user.id)}
|
||||||
|
selected={wo.assignedTo === user.name}
|
||||||
|
onClick={() => {
|
||||||
|
setAssignAnchor(null);
|
||||||
|
assignmentMutation.mutate({ userId: user.id, userName: user.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
<Field label="Source" value={wo.source || "—"} />
|
||||||
|
<Field label="TT" value={wo.tt || "—"} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Classification
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Field label="Problem" value={wo.problem || "—"} />
|
||||||
|
<Field label="Trade" value={wo.trade || "—"} />
|
||||||
|
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field
|
||||||
|
label="Created"
|
||||||
|
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||||
|
/>
|
||||||
|
<Field label="Due Date" value={wo.dueDate || "—"} />
|
||||||
|
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||||
|
<Field label="Completed" value={wo.completedDate || "—"} />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Financial
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Field
|
||||||
|
label="Vendor NTE"
|
||||||
|
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||||
|
/>
|
||||||
|
<Field label="Customer PO" value={wo.po || "—"} />
|
||||||
|
<Field label="Customer NTE" value="Not connected" />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dispatches ({wo.dispatches.length})
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
{wo.dispatches.length ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Dispatch #</TableCell>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell>NTE</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Wait</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{wo.dispatches.map((dispatch) => {
|
||||||
|
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={String(dispatch.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDispatchDetailId(dispatch.id)}
|
||||||
|
>
|
||||||
|
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||||
|
<TableCell>{dispatch.vendorName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{dispatch.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.status === "Sent" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className={
|
||||||
|
waitColor === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: waitColor === "yellow"
|
||||||
|
? "text-yellow-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timeSince(dispatch.dispatchedAt)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={(event) =>
|
||||||
|
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||||
|
}
|
||||||
|
disabled={dispatch.vendorId === ""}
|
||||||
|
>
|
||||||
|
Edit vendor
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No dispatches yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography>{wo.description || "No description provided."}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Activity
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||||
|
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||||
|
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<WorkOrderDetailsSection
|
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||||
wo={wo}
|
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||||
users={users}
|
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||||
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
|
|
||||||
onDispatchClick={setDispatchDetailId}
|
|
||||||
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
|
|
||||||
isCommentPending={commentMutation.isPending}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Type a comment..."
|
||||||
|
disabled={commentMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
className="self-end"
|
||||||
|
disabled={commentMutation.isPending || !commentText.trim()}
|
||||||
|
>
|
||||||
|
{commentMutation.isPending ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
{dispatchOpen && (
|
{dispatchOpen && (
|
||||||
<DispatchCreateModal workOrder={wo} onClose={() => setDispatchOpen(false)} />
|
<DispatchCreateModal
|
||||||
|
workOrder={wo}
|
||||||
|
onClose={() => setDispatchOpen(false)}
|
||||||
|
onDispatched={() => void refetch()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{dispatchDetailId != null && (
|
||||||
{dispatchDetailId && (
|
|
||||||
<DispatchDetailModal
|
<DispatchDetailModal
|
||||||
dispatchId={dispatchDetailId}
|
dispatchId={dispatchDetailId}
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
onClose={() => setDispatchDetailId(null)}
|
onClose={() => setDispatchDetailId(null)}
|
||||||
|
onUpdated={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Dialog
|
||||||
|
open={vendorEditor !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!vendorMutation.isPending) setVendorEditor(null);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} className="pt-2">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{vendorEditor?.vendorName}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
|
||||||
|
{vendorEditorLoading ? (
|
||||||
|
<Box className="flex justify-center p-6">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Technician name"
|
||||||
|
value={vendorEditor?.contactName ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, contactName: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Preferred contact"
|
||||||
|
value={vendorEditor?.preferredContact ?? "Phone"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
preferredContact: event.target.value as VendorPreferredContact,
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="Phone">Phone</MenuItem>
|
||||||
|
<MenuItem value="Email">Email</MenuItem>
|
||||||
|
<MenuItem value="Text">Text</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Phone"
|
||||||
|
value={vendorEditor?.phone ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, phone: formatPhoneMask(event.target.value) }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error={Boolean(
|
||||||
|
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
|
||||||
|
)}
|
||||||
|
helperText="Use (XXX) XXX-XXXX"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={vendorEditor?.email ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, email: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={vendorEditor?.notes ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, notes: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={saveVendorEditor}
|
||||||
|
disabled={
|
||||||
|
!vendorEditor ||
|
||||||
|
vendorEditorLoading ||
|
||||||
|
vendorMutation.isPending ||
|
||||||
|
!isVendorEditorValid(vendorEditor)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vendorMutation.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{value}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { ElementType } from "react";
|
|
||||||
|
|
||||||
type CompDocCoLineProps = {
|
|
||||||
icon: ElementType;
|
|
||||||
text: string;
|
|
||||||
bold?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-1.5 break-words"
|
|
||||||
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
|
|
||||||
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { ElementType } from "react";
|
|
||||||
|
|
||||||
type CompDocDetailRowProps = {
|
|
||||||
icon: ElementType;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
last?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-2.5"
|
|
||||||
style={{
|
|
||||||
padding: "10px 0",
|
|
||||||
borderBottom: last ? "none" : "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={15}
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
marginTop: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
|
||||||
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
|
||||||
import type {
|
|
||||||
CompDocData,
|
|
||||||
CompDocSaveStatus,
|
|
||||||
DocFields,
|
|
||||||
DocState,
|
|
||||||
} from "@/domain/work-orders/types/completion-doc";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
|
||||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
|
||||||
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
|
||||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
|
||||||
import {
|
|
||||||
DOC_STATE_CFG,
|
|
||||||
docStateOf,
|
|
||||||
fmtDocNow,
|
|
||||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
|
||||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
|
||||||
import { CompDocPreview } from "./comp-doc-preview";
|
|
||||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
|
||||||
|
|
||||||
const FOOTER_BTN_SX = {
|
|
||||||
minHeight: 28,
|
|
||||||
height: 28,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.5,
|
|
||||||
gap: 0.75,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type CompDocDialogProps = {
|
|
||||||
row: WorkOrderTableRow;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
data?: CompDocData;
|
|
||||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
|
|
||||||
onStatusChange?: (status: WizardWOStatus) => void;
|
|
||||||
onDateCompletedChange?: (date: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocDialog({
|
|
||||||
row,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
data,
|
|
||||||
onSave,
|
|
||||||
onStatusChange,
|
|
||||||
onDateCompletedChange,
|
|
||||||
}: CompDocDialogProps) {
|
|
||||||
const locId = String(row.locationId || "");
|
|
||||||
const isMockLocation = locId.startsWith("mock-");
|
|
||||||
const { data: apiLocationDetail } = useLocationDetail(
|
|
||||||
isMockLocation || !locId ? undefined : locId,
|
|
||||||
);
|
|
||||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
|
||||||
|
|
||||||
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
|
||||||
|
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [docState, setDocState] = useState<DocState>("draft");
|
|
||||||
const [generatedAt, setGeneratedAt] = useState("");
|
|
||||||
const [editDateCompleted, setEditDateCompleted] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setNotes(data?.notes ?? "");
|
|
||||||
setDocState(docStateOf(row.docStatus));
|
|
||||||
setGeneratedAt(data?.generatedAt ?? "");
|
|
||||||
setEditDateCompleted(row.scheduledOn || "");
|
|
||||||
}, [open, row.id, row.docStatus, row.scheduledOn, data?.notes, data?.generatedAt]);
|
|
||||||
|
|
||||||
const amazonRep = row.pocName || "";
|
|
||||||
const seaRep = row.tech || "";
|
|
||||||
const cfg = DOC_STATE_CFG[docState];
|
|
||||||
const isNotRequired = docState === "not-required";
|
|
||||||
const isGenerated = docState === "generated";
|
|
||||||
|
|
||||||
const docFields: DocFields = {
|
|
||||||
street: site.street,
|
|
||||||
city: site.city,
|
|
||||||
state: site.state,
|
|
||||||
zip: site.zip,
|
|
||||||
dateCompleted: editDateCompleted,
|
|
||||||
amazonRep,
|
|
||||||
seaRep,
|
|
||||||
};
|
|
||||||
|
|
||||||
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
|
||||||
onSave({ notes, generatedAt, ...extra }, status);
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleMarkNotRequired() {
|
|
||||||
setDocState("not-required");
|
|
||||||
persist({}, "NN");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUndo() {
|
|
||||||
setDocState("draft");
|
|
||||||
persist({}, "No");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveDraft() {
|
|
||||||
persist({}, "No");
|
|
||||||
if (editDateCompleted !== row.scheduledOn) {
|
|
||||||
onDateCompletedChange?.(editDateCompleted);
|
|
||||||
}
|
|
||||||
toast(
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
style={{
|
|
||||||
background: "var(--foreground)",
|
|
||||||
color: "#fff",
|
|
||||||
padding: "10px 18px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={16} /> Draft saved
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
position: "bottom-center",
|
|
||||||
autoClose: 3000,
|
|
||||||
hideProgressBar: true,
|
|
||||||
closeButton: false,
|
|
||||||
icon: false,
|
|
||||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
|
||||||
bodyClassName: "!p-0",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGenerate() {
|
|
||||||
const ts = fmtDocNow();
|
|
||||||
setGeneratedAt(ts);
|
|
||||||
setDocState("generated");
|
|
||||||
persist({ generatedAt: ts }, "Yes");
|
|
||||||
if (editDateCompleted !== row.scheduledOn) {
|
|
||||||
onDateCompletedChange?.(editDateCompleted);
|
|
||||||
}
|
|
||||||
onStatusChange?.("Completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDownloadPdf() {
|
|
||||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={() => onOpenChange(false)}
|
|
||||||
maxWidth={false}
|
|
||||||
slotProps={{
|
|
||||||
...dialogBlurBackdropSlotProps,
|
|
||||||
paper: {
|
|
||||||
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
|
||||||
style: {
|
|
||||||
borderRadius: 12,
|
|
||||||
width: "min(1024px, calc(100vw - 32px))",
|
|
||||||
maxWidth: "none",
|
|
||||||
},
|
|
||||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
dividers={false}
|
|
||||||
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
|
||||||
sx={{
|
|
||||||
p: 0,
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
|
||||||
<p className="sr-only">
|
|
||||||
Generate, save or mark not-required the completion document for this work order.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center justify-between"
|
|
||||||
style={{
|
|
||||||
height: 60,
|
|
||||||
padding: "0 20px",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-display)",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
lineHeight: 1.1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Completion Document
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="truncate"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
WO{" "}
|
|
||||||
<span
|
|
||||||
className="font-mono"
|
|
||||||
style={{ color: "var(--color-primary)", fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
#{row.woNumber}
|
|
||||||
</span>
|
|
||||||
{" · "}
|
|
||||||
{serviceTitle(row)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
style={{
|
|
||||||
height: 26,
|
|
||||||
padding: "0 10px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: cfg.pillBg,
|
|
||||||
color: cfg.pillText,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: cfg.pillText,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{cfg.pillLabel}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-label="Close"
|
|
||||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-row">
|
|
||||||
<CompDocPreview
|
|
||||||
row={row}
|
|
||||||
site={site}
|
|
||||||
notes={notes}
|
|
||||||
generatedAt={generatedAt}
|
|
||||||
isGenerated={isGenerated}
|
|
||||||
isNotRequired={isNotRequired}
|
|
||||||
/>
|
|
||||||
<CompDocSidebar
|
|
||||||
row={row}
|
|
||||||
site={site}
|
|
||||||
docState={docState}
|
|
||||||
generatedAt={generatedAt}
|
|
||||||
notes={notes}
|
|
||||||
editDateCompleted={editDateCompleted}
|
|
||||||
amazonRep={amazonRep}
|
|
||||||
seaRep={seaRep}
|
|
||||||
isGenerated={isGenerated}
|
|
||||||
onNotesChange={setNotes}
|
|
||||||
onDateCompletedChange={setEditDateCompleted}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center justify-between gap-3"
|
|
||||||
style={{
|
|
||||||
padding: "14px 20px",
|
|
||||||
borderTop: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{docState === "draft" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
onClick={handleMarkNotRequired}
|
|
||||||
>
|
|
||||||
<Ban size={14} /> Mark as not required
|
|
||||||
</Button>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
>
|
|
||||||
<Save size={14} /> Save draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
|
||||||
onClick={handleGenerate}
|
|
||||||
>
|
|
||||||
<Sparkles size={14} /> Generate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docState === "generated" && (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
|
||||||
style={{ fontSize: 12, color: "var(--color-success)" }}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={14} /> Generated {generatedAt}
|
|
||||||
</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
|
||||||
onClick={handleDownloadPdf}
|
|
||||||
>
|
|
||||||
<Download size={14} /> Download PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docState === "not-required" && (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
|
||||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
|
||||||
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
|
||||||
</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
onClick={handleUndo}
|
|
||||||
>
|
|
||||||
<Undo2 size={14} /> Undo
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
disabled
|
|
||||||
title="Marked not required — undo to generate"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
style={{
|
|
||||||
background: "var(--color-primary)",
|
|
||||||
color: "#fff",
|
|
||||||
opacity: 0.5,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles size={14} /> Generate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
|
|
||||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
|
||||||
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
|
||||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
|
||||||
import { CompDocCoLine } from "./comp-doc-co-line";
|
|
||||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
|
||||||
|
|
||||||
type CompDocPreviewProps = {
|
|
||||||
row: WorkOrderTableRow;
|
|
||||||
site: CompDocSiteInfo;
|
|
||||||
notes: string;
|
|
||||||
generatedAt: string;
|
|
||||||
isGenerated: boolean;
|
|
||||||
isNotRequired: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocPreview({
|
|
||||||
row,
|
|
||||||
site,
|
|
||||||
notes,
|
|
||||||
generatedAt,
|
|
||||||
isGenerated,
|
|
||||||
isNotRequired,
|
|
||||||
}: CompDocPreviewProps) {
|
|
||||||
const proc = procedureFor(row.pm);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex min-h-0 flex-col"
|
|
||||||
style={{
|
|
||||||
width: "58%",
|
|
||||||
borderRight: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
padding: "0 12px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isGenerated ? (
|
|
||||||
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
|
||||||
) : (
|
|
||||||
<FileText size={13} />
|
|
||||||
)}
|
|
||||||
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
|
||||||
style={{ padding: "0 20px 20px" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative break-words"
|
|
||||||
style={{
|
|
||||||
background: "#ffffff",
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
|
||||||
overflow: "hidden",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
overflowWrap: "break-word",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: isNotRequired ? 0.5 : 1,
|
|
||||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 12,
|
|
||||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0 28px 28px" }}>
|
|
||||||
<div
|
|
||||||
className="flex flex-wrap items-start justify-between gap-y-3"
|
|
||||||
style={{ padding: "20px 0 12px" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src="/seahaven-logo.svg"
|
|
||||||
alt="Seahaven Industries"
|
|
||||||
style={{ height: 34, width: "auto", display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
|
||||||
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
|
||||||
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
|
||||||
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
|
||||||
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isGenerated && (
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: "var(--doc-yes-bg)",
|
|
||||||
color: "var(--color-success)",
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{serviceTitle(row).toUpperCase()}
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#1C75BC",
|
|
||||||
margin: "2px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
WO# {row.woNumber}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 14 }}>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "2px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Site ID — {row.site}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
|
||||||
Amazon.com Services LLC {row.site}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.street},</p>
|
|
||||||
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.label}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
|
||||||
{row.tech || "—"}
|
|
||||||
{row.company ? ` · ${row.company}` : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
|
||||||
{SAFETY_BULLETS.map((b, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
margin: "5px 0",
|
|
||||||
color: "#161E38",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{b}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{proc.heading}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: "#58595B",
|
|
||||||
margin: "0 0 8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{proc.intro}
|
|
||||||
</p>
|
|
||||||
<CompDocProcedureList proc={proc} />
|
|
||||||
|
|
||||||
{notes && (
|
|
||||||
<>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PM Comments
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: 0,
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notes}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
|
||||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isNotRequired && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-2"
|
|
||||||
style={{
|
|
||||||
marginTop: "30%",
|
|
||||||
transform: "rotate(-15deg)",
|
|
||||||
border: "3px solid var(--warning)",
|
|
||||||
color: "var(--warning)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "8px 18px",
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 22,
|
|
||||||
letterSpacing: "0.06em",
|
|
||||||
background: "rgba(255,255,255,0.65)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ban size={20} /> NOT REQUIRED
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
|
||||||
|
|
||||||
type CompDocProcedureListProps = {
|
|
||||||
proc: Procedure;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
|
||||||
return (
|
|
||||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
|
||||||
{proc.steps.map((st, i) => (
|
|
||||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
|
||||||
{st.text}
|
|
||||||
{st.sub && (
|
|
||||||
<ol
|
|
||||||
style={{
|
|
||||||
listStyle: "lower-alpha",
|
|
||||||
paddingLeft: 22,
|
|
||||||
margin: "4px 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{st.sub.map((x, j) => (
|
|
||||||
<li key={j} style={{ margin: "2px 0" }}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
|
||||||
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import {
|
|
||||||
DOC_STATE_CFG,
|
|
||||||
docStatusCardDescription,
|
|
||||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
|
||||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
|
||||||
|
|
||||||
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
|
|
||||||
|
|
||||||
const COMP_DOC_INPUT_STYLE: CSSProperties = {
|
|
||||||
fontSize: 13,
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
};
|
|
||||||
|
|
||||||
type CompDocSidebarProps = {
|
|
||||||
row: WorkOrderTableRow;
|
|
||||||
site: CompDocSiteInfo;
|
|
||||||
docState: DocState;
|
|
||||||
generatedAt: string;
|
|
||||||
notes: string;
|
|
||||||
editDateCompleted: string;
|
|
||||||
amazonRep: string;
|
|
||||||
seaRep: string;
|
|
||||||
isGenerated: boolean;
|
|
||||||
onNotesChange: (value: string) => void;
|
|
||||||
onDateCompletedChange: (value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CompDocSidebar({
|
|
||||||
row,
|
|
||||||
site,
|
|
||||||
docState,
|
|
||||||
generatedAt,
|
|
||||||
notes,
|
|
||||||
editDateCompleted,
|
|
||||||
amazonRep,
|
|
||||||
seaRep,
|
|
||||||
isGenerated,
|
|
||||||
onNotesChange,
|
|
||||||
onDateCompletedChange,
|
|
||||||
}: CompDocSidebarProps) {
|
|
||||||
const cfg = DOC_STATE_CFG[docState];
|
|
||||||
const StateIcon = cfg.icon;
|
|
||||||
|
|
||||||
const editRow = (
|
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
onChange: (v: string) => void,
|
|
||||||
type = "text",
|
|
||||||
placeholder = "",
|
|
||||||
) => (
|
|
||||||
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
|
|
||||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
readOnly={isGenerated}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={COMP_DOC_INPUT_CLS}
|
|
||||||
style={{
|
|
||||||
...COMP_DOC_INPUT_STYLE,
|
|
||||||
...(isGenerated
|
|
||||||
? {
|
|
||||||
opacity: 0.6,
|
|
||||||
cursor: "default",
|
|
||||||
pointerEvents: "none" as const,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex min-h-0 flex-col"
|
|
||||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
|
||||||
>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-3"
|
|
||||||
style={{
|
|
||||||
padding: 14,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: cfg.cardBg,
|
|
||||||
border: `1px solid ${cfg.cardBorder}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center justify-center"
|
|
||||||
style={{
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: cfg.cardIconBg,
|
|
||||||
border: `1px solid ${cfg.cardBorder}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 13.5,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cfg.cardTitle}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
lineHeight: 1.45,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
marginTop: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{docStatusCardDescription(docState, generatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.1em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Work Order Details
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
|
||||||
<CompDocDetailRow
|
|
||||||
icon={MapPin}
|
|
||||||
label="Site Code"
|
|
||||||
value={`${row.site} · Amazon.com Services LLC`}
|
|
||||||
/>
|
|
||||||
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
|
||||||
{editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
|
|
||||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
|
||||||
<CompDocDetailRow
|
|
||||||
icon={Wrench}
|
|
||||||
label="Seahaven Representative"
|
|
||||||
value={seaRep || "—"}
|
|
||||||
last
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 18 }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PM Comments{" "}
|
|
||||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
|
||||||
— optional, included in the document
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => onNotesChange(e.target.value)}
|
|
||||||
readOnly={isGenerated}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
|
||||||
className="mt-2 w-full resize-none"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "10px 12px",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
outline: "none",
|
|
||||||
...(isGenerated
|
|
||||||
? {
|
|
||||||
opacity: 0.6,
|
|
||||||
cursor: "default",
|
|
||||||
pointerEvents: "none" as const,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Button, Menu, MenuItem } from "@mui/material";
|
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
|
||||||
|
|
||||||
type WorkOrderAssigneeMenuProps = {
|
|
||||||
assignedTo: string;
|
|
||||||
users: WorkOrderUser[];
|
|
||||||
onAssign: (userId: string | number, userName: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrderAssigneeMenuProps) {
|
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button size="small" onClick={(event) => setAnchor(event.currentTarget)}>
|
|
||||||
{assignedTo || "Unassigned"}
|
|
||||||
</Button>
|
|
||||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
onAssign("", "");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unassigned
|
|
||||||
</MenuItem>
|
|
||||||
{users.map((user) => (
|
|
||||||
<MenuItem
|
|
||||||
key={String(user.id)}
|
|
||||||
selected={assignedTo === user.name}
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
onAssign(user.id, user.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
||||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
|
||||||
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
|
||||||
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
|
||||||
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
|
|
||||||
import type {
|
|
||||||
WorkOrderAuditEntry,
|
|
||||||
WorkOrderDetail,
|
|
||||||
WorkOrderUser,
|
|
||||||
} from "@/domain/work-orders/types/work-order";
|
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
type ActivityTab = "customer" | "internal" | "audit";
|
|
||||||
|
|
||||||
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
|
||||||
const sorted = useMemo(
|
|
||||||
() =>
|
|
||||||
[...entries].sort(
|
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
||||||
),
|
|
||||||
[entries],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sorted.length) {
|
|
||||||
return <Typography color="text.secondary">No changes recorded.</Typography>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="max-h-72 space-y-4 overflow-y-auto">
|
|
||||||
{sorted.map((entry) => (
|
|
||||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
|
||||||
<Box className="mb-1 flex justify-between">
|
|
||||||
<Typography variant="caption" className="font-semibold">
|
|
||||||
{entry.userName || "System"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkOrderDetailsSectionProps = {
|
|
||||||
wo: WorkOrderDetail;
|
|
||||||
users: WorkOrderUser[];
|
|
||||||
onAssign: (userId: string | number, userName: string) => void;
|
|
||||||
onDispatchClick: (dispatchId: string | number) => void;
|
|
||||||
onCommentSubmit: (text: string, commentType: "customer" | "internal") => void;
|
|
||||||
isCommentPending: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkOrderDetailsSection({
|
|
||||||
wo,
|
|
||||||
users,
|
|
||||||
onAssign,
|
|
||||||
onDispatchClick,
|
|
||||||
onCommentSubmit,
|
|
||||||
isCommentPending,
|
|
||||||
}: WorkOrderDetailsSectionProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
|
||||||
const [commentText, setCommentText] = useState("");
|
|
||||||
|
|
||||||
const customerComments = useMemo(
|
|
||||||
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
|
||||||
[wo.comments],
|
|
||||||
);
|
|
||||||
const internalComments = useMemo(
|
|
||||||
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
|
||||||
[wo.comments],
|
|
||||||
);
|
|
||||||
const auditLog = wo.auditLog ?? [];
|
|
||||||
|
|
||||||
const handleCommentSubmit = (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!commentText.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal");
|
|
||||||
setCommentText("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
Details
|
|
||||||
</Typography>
|
|
||||||
<DetailFieldGrid>
|
|
||||||
<DetailField label="Internal WO" value={wo.internalWONumber} />
|
|
||||||
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
|
||||||
<DetailField label="Priority" value={wo.priority || "—"} />
|
|
||||||
<DetailField label="Location" value={wo.location || "—"} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
|
||||||
Assigned To
|
|
||||||
</Typography>
|
|
||||||
<WorkOrderAssigneeMenu assignedTo={wo.assignedTo} users={users} onAssign={onAssign} />
|
|
||||||
</Box>
|
|
||||||
<DetailField label="Source" value={wo.source || "—"} />
|
|
||||||
<DetailField label="TT" value={wo.tt || "—"} />
|
|
||||||
</DetailFieldGrid>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography fontWeight={600}>Classification</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<DetailFieldGrid>
|
|
||||||
<DetailField label="Problem" value={wo.problem || "—"} />
|
|
||||||
<DetailField label="Trade" value={wo.trade || "—"} />
|
|
||||||
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
|
|
||||||
</DetailFieldGrid>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography fontWeight={600}>Schedule</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailField
|
|
||||||
label="Created"
|
|
||||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
|
||||||
/>
|
|
||||||
<DetailField label="Due Date" value={wo.dueDate || "—"} />
|
|
||||||
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
|
|
||||||
<DetailField label="Completed" value={wo.completedDate || "—"} />
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography fontWeight={600}>Financial</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<DetailField
|
|
||||||
label="Vendor NTE"
|
|
||||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
|
||||||
/>
|
|
||||||
<DetailField label="Customer PO" value={wo.po || "—"} />
|
|
||||||
<DetailField label="Customer NTE" value="Not connected" />
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
{wo.dispatches.length ? (
|
|
||||||
<TableContainer>
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Dispatch #</TableCell>
|
|
||||||
<TableCell>Vendor</TableCell>
|
|
||||||
<TableCell>NTE</TableCell>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Date</TableCell>
|
|
||||||
<TableCell>Wait</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{wo.dispatches.map((dispatch) => {
|
|
||||||
const color = waitTimeColor(dispatch.dispatchedAt);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={String(dispatch.id)}
|
|
||||||
hover
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onDispatchClick(dispatch.id)}
|
|
||||||
>
|
|
||||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
|
||||||
<TableCell>{dispatch.vendorName}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{dispatch.status}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{dispatch.status === "Sent" ? (
|
|
||||||
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
|
|
||||||
{timeSince(dispatch.dispatchedAt)}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">No dispatches yet.</Typography>
|
|
||||||
)}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography fontWeight={600}>Description</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Typography>{wo.description || "No description provided."}</Typography>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
|
||||||
Activity
|
|
||||||
</Typography>
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
|
||||||
className="mb-3"
|
|
||||||
>
|
|
||||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
|
||||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
|
||||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{activeTab === "customer" && (
|
|
||||||
<CommentTimeline
|
|
||||||
items={mapWorkOrderCommentsToTimeline(customerComments)}
|
|
||||||
emptyMessage="No comments yet."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === "internal" && (
|
|
||||||
<CommentTimeline
|
|
||||||
items={mapWorkOrderCommentsToTimeline(internalComments)}
|
|
||||||
emptyMessage="No comments yet."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
|
||||||
|
|
||||||
{activeTab === "internal" && (
|
|
||||||
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
value={commentText}
|
|
||||||
onChange={(event) => setCommentText(event.target.value)}
|
|
||||||
placeholder="Type a comment..."
|
|
||||||
disabled={isCommentPending}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
className="self-end"
|
|
||||||
disabled={isCommentPending || !commentText.trim()}
|
|
||||||
>
|
|
||||||
{isCommentPending ? "Sending..." : "Send"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Box, Button, CircularProgress, IconButton, Tab, Tabs, Typography } from "@mui/material";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import { SideSheet } from "@/components/ui/side-sheet";
|
|
||||||
import { WorkOrderDetailsSection } from "@/app/(protected)/workorders/_components/detail/work-order-detail-sections";
|
|
||||||
import { WorkOrderStatusMenu } from "@/app/(protected)/workorders/_components/detail/work-order-status-menu";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import {
|
|
||||||
useAddWorkOrderComment,
|
|
||||||
useChangeWorkOrderAssignment,
|
|
||||||
useChangeWorkOrderStatus,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
|
||||||
|
|
||||||
type WorkOrderSlideOverProps = {
|
|
||||||
row: WorkOrderTableRow | null;
|
|
||||||
editMode: boolean;
|
|
||||||
users: WorkOrderUser[];
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkOrderSlideOver({ row, editMode, users, onClose }: WorkOrderSlideOverProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [tab, setTab] = useState(0);
|
|
||||||
const workOrderId = row?.id;
|
|
||||||
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId ?? "");
|
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId ?? "");
|
|
||||||
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SideSheet
|
|
||||||
open={Boolean(row)}
|
|
||||||
onClose={onClose}
|
|
||||||
widthClass="w-full max-w-xl"
|
|
||||||
aria-label="Work order details"
|
|
||||||
>
|
|
||||||
<Box className="flex h-full flex-col">
|
|
||||||
<Box className="flex items-start justify-between border-b border-border px-4 py-3">
|
|
||||||
<Box>
|
|
||||||
<Typography variant="overline" color="text.secondary">
|
|
||||||
Work Order
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h6" component="h2">
|
|
||||||
{row?.woNumber || row?.title || "—"}
|
|
||||||
</Typography>
|
|
||||||
{wo && (
|
|
||||||
<Box className="mt-1">
|
|
||||||
<WorkOrderStatusMenu
|
|
||||||
status={wo.status}
|
|
||||||
onStatusChange={(status) => statusMutation.mutate(status)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} className="border-b border-border px-2">
|
|
||||||
<Tab label="Info" />
|
|
||||||
<Tab label="Activity" />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Box className="flex-1 overflow-y-auto p-4">
|
|
||||||
{isLoading && (
|
|
||||||
<Box className="flex justify-center py-12">
|
|
||||||
<CircularProgress size={28} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Typography color="error" variant="body2">
|
|
||||||
{error instanceof Error ? error.message : "Failed to load work order."}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{wo && tab === 0 && (
|
|
||||||
<Box className="space-y-3">
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Title:</strong> {wo.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Location:</strong> {wo.location}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Assigned:</strong> {wo.assignedTo || "Unassigned"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Due:</strong> {wo.dueDate || "—"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Scheduled:</strong> {wo.scheduledDate || "—"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Problem:</strong> {wo.problem || "—"}
|
|
||||||
</Typography>
|
|
||||||
{wo.description && (
|
|
||||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
|
||||||
{wo.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
navigate(`/workorders/${wo.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open full page
|
|
||||||
</Button>
|
|
||||||
{editMode && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
navigate(`/workorders/edit/${wo.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit work order
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{wo && tab === 1 && (
|
|
||||||
<WorkOrderDetailsSection
|
|
||||||
wo={wo}
|
|
||||||
users={users}
|
|
||||||
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
|
|
||||||
onDispatchClick={() => {}}
|
|
||||||
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
|
|
||||||
isCommentPending={commentMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SideSheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { Menu, MenuItem } from "@mui/material";
|
|
||||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
|
||||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
|
||||||
|
|
||||||
type WorkOrderStatusMenuProps = {
|
|
||||||
status: string;
|
|
||||||
onStatusChange: (status: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusMenuProps) {
|
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MenuAnchorButton onAnchor={setAnchor} open={open} aria-label="Change status">
|
|
||||||
<WorkOrderStatusBadge status={status} />
|
|
||||||
</MenuAnchorButton>
|
|
||||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
|
|
||||||
<MenuItem
|
|
||||||
key={option}
|
|
||||||
selected={option === status}
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
if (option !== status) {
|
|
||||||
onStatusChange(option);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,22 +15,31 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import { useWorkOrdersByLocation } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
||||||
import {
|
import {
|
||||||
useTaskListTemplates,
|
useTaskListTemplates,
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
interface SelectedWorkOrder {
|
||||||
|
id: string | number;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DispatchCreateModalProps {
|
interface DispatchCreateModalProps {
|
||||||
workOrder: WorkOrderDetail;
|
workOrder: WorkOrderDetail;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDispatched?: () => void;
|
onDispatched: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalProps) {
|
export function DispatchCreateModal({
|
||||||
|
workOrder,
|
||||||
|
onClose,
|
||||||
|
onDispatched,
|
||||||
|
}: DispatchCreateModalProps) {
|
||||||
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
||||||
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
|
|
@ -39,7 +48,7 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
||||||
const [customItems, setCustomItems] = useState<string[]>([]);
|
const [customItems, setCustomItems] = useState<string[]>([]);
|
||||||
const [newCustomItem, setNewCustomItem] = useState("");
|
const [newCustomItem, setNewCustomItem] = useState("");
|
||||||
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
|
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
|
||||||
{
|
{
|
||||||
id: workOrder.id,
|
id: workOrder.id,
|
||||||
number: workOrder.internalWONumber,
|
number: workOrder.internalWONumber,
|
||||||
|
|
@ -47,7 +56,6 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [woSearch, setWoSearch] = useState("");
|
const [woSearch, setWoSearch] = useState("");
|
||||||
const debouncedWoSearch = useDebounce(woSearch, 300);
|
|
||||||
|
|
||||||
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
||||||
workOrder.trade,
|
workOrder.trade,
|
||||||
|
|
@ -56,10 +64,14 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
const { data: taskTemplates = [] } = useTaskListTemplates();
|
const { data: taskTemplates = [] } = useTaskListTemplates();
|
||||||
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
||||||
|
|
||||||
const { data: siteWorkOrdersData } = useWorkOrdersByLocation(
|
const { data: siteWorkOrdersData } = useWorkOrdersList({
|
||||||
workOrder.locationId,
|
search: "",
|
||||||
debouncedWoSearch,
|
page: 1,
|
||||||
);
|
pageSize: 500,
|
||||||
|
sortBy: "number",
|
||||||
|
sortDir: "asc",
|
||||||
|
locationId: workOrder.locationId,
|
||||||
|
});
|
||||||
|
|
||||||
const siteWorkOrders = useMemo(
|
const siteWorkOrders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -81,8 +93,14 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
() =>
|
() =>
|
||||||
siteWorkOrders
|
siteWorkOrders
|
||||||
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!woSearch ||
|
||||||
|
item.woNumber.includes(woSearch) ||
|
||||||
|
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
||||||
|
)
|
||||||
.slice(0, 10),
|
.slice(0, 10),
|
||||||
[siteWorkOrders, selectedWOs],
|
[siteWorkOrders, selectedWOs, woSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -100,7 +118,12 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
||||||
customChecklistItems: customItems.length > 0 ? customItems : null,
|
customChecklistItems: customItems.length > 0 ? customItems : null,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => onClose() },
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onDispatched();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -115,13 +138,18 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent className="flex flex-col gap-4">
|
<DialogContent className="flex flex-col gap-4">
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="mb-2">
|
<Typography variant="subtitle2" className="mb-2">
|
||||||
Work Orders ({selectedWOs.length})
|
Work Orders ({selectedWOs.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{selectedWOs.map((wo) => (
|
{selectedWOs.map((wo) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={String(wo.id)}
|
key={String(wo.id)}
|
||||||
|
|
@ -180,9 +208,21 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
/>
|
/>
|
||||||
{isLoadingVendors ? (
|
{isLoadingVendors ? (
|
||||||
<Typography color="text.secondary">Loading vendors...</Typography>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading vendors...
|
||||||
|
</Typography>
|
||||||
) : filteredVendors.length === 0 ? (
|
) : filteredVendors.length === 0 ? (
|
||||||
<Typography color="text.secondary">No vendors found</Typography>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No vendors found
|
||||||
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
||||||
{filteredVendors.map((vendor) => (
|
{filteredVendors.map((vendor) => (
|
||||||
|
|
@ -202,13 +242,25 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
<Typography variant="body2" className="font-semibold">
|
<Typography variant="body2" className="font-semibold">
|
||||||
{vendor.companyName}
|
{vendor.companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vendor.tradeSpecialties && (
|
{Boolean(vendor.tradeSpecialties) && (
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{vendor.tradeSpecialties}
|
{vendor.tradeSpecialties}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{vendor.address && (
|
{Boolean(vendor.address) && (
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{vendor.address}
|
{vendor.address}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
@ -230,8 +282,10 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
type="number"
|
type="number"
|
||||||
value={nteAmount}
|
value={nteAmount}
|
||||||
onChange={(event) => setNteAmount(event.target.value)}
|
onChange={(event) => setNteAmount(event.target.value)}
|
||||||
inputProps={{ step: "0.01", min: 0 }}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { step: "0.01", min: 0 },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Vendor Scheduled Date"
|
label="Vendor Scheduled Date"
|
||||||
|
|
@ -259,7 +313,13 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
{customItems.length > 0 && (
|
{customItems.length > 0 && (
|
||||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{customItems.map((item, index) => (
|
{customItems.map((item, index) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={`${item}-${index}`}
|
key={`${item}-${index}`}
|
||||||
|
|
@ -294,7 +354,6 @@ export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalP
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1 +1,782 @@
|
||||||
export { DispatchDetailModal } from "./dispatch/dispatch-detail";
|
import { useMemo, useState } from "react";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||||
|
import {
|
||||||
|
useApproveUplift,
|
||||||
|
useDenyUplift,
|
||||||
|
useUpliftsForDispatch,
|
||||||
|
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||||
|
import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import {
|
||||||
|
useAddChecklistItem,
|
||||||
|
useAddDispatchComment,
|
||||||
|
useAddDispatchSignoff,
|
||||||
|
useUpdateChecklistItem,
|
||||||
|
useUpdateDispatch,
|
||||||
|
useVerifyDispatch,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
timeBetween,
|
||||||
|
timeSince,
|
||||||
|
toDateInputValue,
|
||||||
|
waitTimeColor,
|
||||||
|
} from "@/lib/time-utils";
|
||||||
|
|
||||||
|
interface DispatchDetailModalProps {
|
||||||
|
dispatchId: string | number;
|
||||||
|
workOrderId: string | number;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DispatchDetailModal({
|
||||||
|
dispatchId,
|
||||||
|
workOrderId,
|
||||||
|
onClose,
|
||||||
|
onUpdated,
|
||||||
|
}: DispatchDetailModalProps) {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
||||||
|
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
status: "",
|
||||||
|
nteAmount: "",
|
||||||
|
scheduledDate: "",
|
||||||
|
completedDate: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
const [newChecklistItem, setNewChecklistItem] = useState("");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [sendEmail, setSendEmail] = useState(true);
|
||||||
|
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
||||||
|
const addCommentMutation = useAddDispatchComment(dispatchId);
|
||||||
|
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
|
||||||
|
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
||||||
|
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||||
|
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||||
|
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||||
|
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||||
|
|
||||||
|
const userName =
|
||||||
|
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||||
|
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
|
||||||
|
"User";
|
||||||
|
|
||||||
|
const beginEditing = () => {
|
||||||
|
if (dispatch) {
|
||||||
|
setForm({
|
||||||
|
status: dispatch.status || "",
|
||||||
|
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
||||||
|
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
||||||
|
completedDate: toDateInputValue(dispatch.completedDate),
|
||||||
|
description: dispatch.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyState = useMemo(() => {
|
||||||
|
if (!dispatch) {
|
||||||
|
return { canVerify: false, missing: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklistTotal = dispatch.checklistItems.length;
|
||||||
|
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
|
||||||
|
const signoffCount = dispatch.signoffs.length;
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (checklistTotal > 0 && checklistDone < checklistTotal) {
|
||||||
|
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signoffCount < 2) {
|
||||||
|
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
|
||||||
|
if (!types.includes("Customer")) {
|
||||||
|
missing.push("Customer sign-off");
|
||||||
|
}
|
||||||
|
if (!types.includes("Vendor")) {
|
||||||
|
missing.push("Vendor sign-off");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
missing,
|
||||||
|
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateDispatchMutation.mutate(
|
||||||
|
{
|
||||||
|
id: dispatchId,
|
||||||
|
status: form.status,
|
||||||
|
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
|
||||||
|
scheduledDate: form.scheduledDate || null,
|
||||||
|
completedDate: form.completedDate || null,
|
||||||
|
description: form.description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditing(false);
|
||||||
|
onUpdated();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComment = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommentMutation.mutate(
|
||||||
|
{ text: commentText.trim(), sendEmail },
|
||||||
|
{
|
||||||
|
onSuccess: () => setCommentText(""),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
|
||||||
|
const note = window.prompt("Optional approval note:", "") || "";
|
||||||
|
if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDenyUplift = (upliftId: string | number) => {
|
||||||
|
const note = window.prompt("Denial reason (required):", "");
|
||||||
|
if (!note?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogContent className="flex justify-center py-12">
|
||||||
|
<CircularProgress />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dispatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocked = isDispatchLocked(dispatch);
|
||||||
|
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
||||||
|
<DialogTitle className="flex items-start justify-between gap-4 pr-2">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PO: {dispatch.poNumber} ·{" "}
|
||||||
|
{dispatch.workOrders.length > 0
|
||||||
|
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
||||||
|
: `WO: ${dispatch.woNumber}`}{" "}
|
||||||
|
· {dispatch.vendorEmail}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent className="flex flex-col gap-6">
|
||||||
|
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Status"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, status: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="NTE Amount"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
value={form.nteAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, nteAmount: event.target.value }))
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { step: "0.01" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Scheduled Date"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={form.scheduledDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, scheduledDate: event.target.value }))
|
||||||
|
}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Completed Date"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={form.completedDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, completedDate: event.target.value }))
|
||||||
|
}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
size="small"
|
||||||
|
className="sm:col-span-2"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, description: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateDispatchMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateDispatchMutation.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DetailField label="Status" value={dispatch.status} />
|
||||||
|
<DetailField
|
||||||
|
label="NTE"
|
||||||
|
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label="Scheduled"
|
||||||
|
value={toDateInputValue(dispatch.scheduledDate) || "—"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label="Completed"
|
||||||
|
value={toDateInputValue(dispatch.completedDate) || "—"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label="Dispatched"
|
||||||
|
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label="Accepted"
|
||||||
|
value={
|
||||||
|
dispatch.acknowledgedAt
|
||||||
|
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
||||||
|
: dispatch.status === "Sent"
|
||||||
|
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
highlight={
|
||||||
|
dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
||||||
|
{Boolean(dispatch.description) && (
|
||||||
|
<DetailField
|
||||||
|
label="Description"
|
||||||
|
value={dispatch.description}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLocked && (
|
||||||
|
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateDispatchMutation.mutate(
|
||||||
|
{
|
||||||
|
id: dispatchId,
|
||||||
|
status: "Cancelled",
|
||||||
|
nteAmount: dispatch.nteAmount,
|
||||||
|
scheduledDate: toDateInputValue(dispatch.scheduledDate) || null,
|
||||||
|
completedDate: toDateInputValue(dispatch.completedDate) || null,
|
||||||
|
description: dispatch.description,
|
||||||
|
},
|
||||||
|
{ onSuccess: onUpdated },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Dispatch
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={beginEditing}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(dispatch.checklistItems.length > 0 || !isLocked) && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||||
|
Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/
|
||||||
|
{dispatch.checklistItems.length})
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{dispatch.checklistItems.map((item) => (
|
||||||
|
<Stack
|
||||||
|
key={String(item.id)}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.isCompleted}
|
||||||
|
disabled={isLocked || updateChecklistMutation.isPending}
|
||||||
|
onChange={() =>
|
||||||
|
updateChecklistMutation.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
isCompleted: !item.isCompleted,
|
||||||
|
completedBy: userName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box className="flex-1">
|
||||||
|
<Typography variant="body2">{item.itemText}</Typography>
|
||||||
|
{item.isCompleted && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.completedBy}
|
||||||
|
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{!isLocked && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newChecklistItem}
|
||||||
|
onChange={(event) => setNewChecklistItem(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && newChecklistItem.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
addChecklistMutation.mutate(newChecklistItem.trim(), {
|
||||||
|
onSuccess: () => setNewChecklistItem(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add checklist item..."
|
||||||
|
fullWidth
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||||
|
Sign-offs
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
{(["Customer", "Vendor"] as const).map((type) => {
|
||||||
|
const signoff = dispatch.signoffs.find((item) => item.signoffType === type);
|
||||||
|
return (
|
||||||
|
<Box key={type} className="flex-1 rounded border border-border p-3">
|
||||||
|
<Typography variant="subtitle2">{type}</Typography>
|
||||||
|
{signoff ? (
|
||||||
|
<Box className="mt-2">
|
||||||
|
{signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
|
||||||
|
<img
|
||||||
|
src={signoff.signature}
|
||||||
|
alt="Signature"
|
||||||
|
className="mb-2 max-h-20 max-w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{signoff.signatureMethod === "typed" && (
|
||||||
|
<Typography variant="h6" className="italic">
|
||||||
|
{signoff.signature}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signoff.name}
|
||||||
|
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => setSigCaptureType(type)}
|
||||||
|
disabled={isLocked}
|
||||||
|
>
|
||||||
|
{isLocked ? "Not signed" : "Collect Signature"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
{dispatch.signoffs.length >= 2 && (
|
||||||
|
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||||
|
NTE Uplift Requests
|
||||||
|
</Typography>
|
||||||
|
{uplifts.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No uplift requests.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{uplifts.map((uplift) => {
|
||||||
|
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip label={uplift.status} size="small" />
|
||||||
|
{uplift.requiredTier === 2 && (
|
||||||
|
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">
|
||||||
|
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
||||||
|
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
||||||
|
{Number(uplift.delta).toFixed(2)})
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{Boolean(uplift.vendorReason) && (
|
||||||
|
<Typography variant="body2" className="mt-1">
|
||||||
|
<strong>Reason:</strong> {uplift.vendorReason}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="mt-1"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||||
|
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{uplift.status !== "Pending" &&
|
||||||
|
Boolean(
|
||||||
|
uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
|
||||||
|
) && (
|
||||||
|
<Box className="mt-1">
|
||||||
|
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uplift.status} by {uplift.decidedByName || "—"}
|
||||||
|
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{Boolean(uplift.decisionNote) && (
|
||||||
|
<Typography variant="body2" className="mt-0.5">
|
||||||
|
<strong>Note:</strong> {uplift.decisionNote}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{uplift.status === "Pending" && (
|
||||||
|
<Stack direction="row" spacing={1} className="mt-2">
|
||||||
|
<Tooltip
|
||||||
|
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canDecide}
|
||||||
|
onClick={() => handleApproveUplift(uplift.id, uplift.requestedNTE)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canDecide}
|
||||||
|
onClick={() => handleDenyUplift(uplift.id)}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||||
|
Vendor Communication
|
||||||
|
</Typography>
|
||||||
|
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
||||||
|
{dispatch.comments.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No messages yet.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{dispatch.comments.map((comment) => (
|
||||||
|
<Box key={String(comment.id)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{comment.userName || comment.commenter || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box component="form" onSubmit={handleComment}>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Message vendor..."
|
||||||
|
fullWidth
|
||||||
|
disabled={isLocked || addCommentMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
className="mt-2"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={sendEmail}
|
||||||
|
onChange={(event) => setSendEmail(event.target.checked)}
|
||||||
|
disabled={isLocked}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Send email"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isLocked || addCommentMutation.isPending || !commentText.trim()}
|
||||||
|
>
|
||||||
|
{addCommentMutation.isPending ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{dispatch.verifiedAt ? (
|
||||||
|
<Alert severity="success">
|
||||||
|
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{verifyState.missing.length > 0 && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Missing: {verifyState.missing.join(", ")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
disabled={!verifyState.canVerify || verifyMutation.isPending}
|
||||||
|
onClick={() => verifyMutation.mutate(undefined, { onSuccess: onUpdated })}
|
||||||
|
>
|
||||||
|
Verify & Close
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{sigCaptureType != null && (
|
||||||
|
<SignatureCapture
|
||||||
|
title={`${sigCaptureType} Sign-off`}
|
||||||
|
onClose={() => setSigCaptureType(null)}
|
||||||
|
onSave={(signature, method, name) => {
|
||||||
|
addSignoffMutation.mutate(
|
||||||
|
{
|
||||||
|
signoffType: sigCaptureType,
|
||||||
|
name,
|
||||||
|
signature,
|
||||||
|
signatureMethod: method,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setSigCaptureType(null) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
highlight?: "green" | "yellow" | "red" | "";
|
||||||
|
}) {
|
||||||
|
const colorClass =
|
||||||
|
highlight === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: highlight === "yellow"
|
||||||
|
? "text-yellow-600"
|
||||||
|
: highlight === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={className}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className={colorClass}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
|
||||||
import { toDateInputValue } from "@/lib/time-utils";
|
|
||||||
import type { DispatchEditFormState } from "./dispatch-edit-form";
|
|
||||||
|
|
||||||
export function buildDispatchEditForm(dispatch: DispatchDetail): DispatchEditFormState {
|
|
||||||
return {
|
|
||||||
status: dispatch.status || "",
|
|
||||||
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
|
||||||
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
|
||||||
completedDate: toDateInputValue(dispatch.completedDate),
|
|
||||||
description: dispatch.description || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { Box, Checkbox, Stack, TextField, Typography } from "@mui/material";
|
|
||||||
import type { DispatchChecklistItem } from "@/domain/work-orders/types/work-order";
|
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
type DispatchChecklistSectionProps = {
|
|
||||||
items: DispatchChecklistItem[];
|
|
||||||
isLocked: boolean;
|
|
||||||
isUpdating: boolean;
|
|
||||||
userName: string;
|
|
||||||
newItem: string;
|
|
||||||
onNewItemChange: (value: string) => void;
|
|
||||||
onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
|
|
||||||
onAddItem: (text: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchChecklistSection({
|
|
||||||
items,
|
|
||||||
isLocked,
|
|
||||||
isUpdating,
|
|
||||||
userName,
|
|
||||||
newItem,
|
|
||||||
onNewItemChange,
|
|
||||||
onToggleItem,
|
|
||||||
onAddItem,
|
|
||||||
}: DispatchChecklistSectionProps) {
|
|
||||||
if (items.length === 0 && isLocked) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedCount = items.filter((item) => item.isCompleted).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
||||||
Checklist ({completedCount}/{items.length})
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<Stack key={String(item.id)} direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Checkbox
|
|
||||||
checked={item.isCompleted}
|
|
||||||
disabled={isLocked || isUpdating}
|
|
||||||
onChange={() => onToggleItem(item.id, !item.isCompleted)}
|
|
||||||
/>
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="body2">{item.itemText}</Typography>
|
|
||||||
{item.isCompleted && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{item.completedBy || userName}
|
|
||||||
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{!isLocked && (
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={newItem}
|
|
||||||
onChange={(event) => onNewItemChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && newItem.trim()) {
|
|
||||||
event.preventDefault();
|
|
||||||
onAddItem(newItem.trim());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add checklist item..."
|
|
||||||
fullWidth
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import { Box, IconButton, Typography } from "@mui/material";
|
|
||||||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
|
||||||
|
|
||||||
type DispatchDetailHeaderProps = {
|
|
||||||
dispatch: DispatchDetail;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchDetailHeader({ dispatch, onClose }: DispatchDetailHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Box className="flex items-start justify-between gap-4 pr-2">
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">
|
|
||||||
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
PO: {dispatch.poNumber} ·{" "}
|
|
||||||
{dispatch.workOrders.length > 0
|
|
||||||
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
|
||||||
: `WO: ${dispatch.woNumber}`}{" "}
|
|
||||||
· {dispatch.vendorEmail}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { Box, Button, MenuItem, Stack, TextField } from "@mui/material";
|
|
||||||
import { DetailField } from "@/components/common/detail-field";
|
|
||||||
import {
|
|
||||||
DISPATCH_STATUS_OPTIONS,
|
|
||||||
type DispatchDetail,
|
|
||||||
} from "@/domain/work-orders/types/work-order";
|
|
||||||
import {
|
|
||||||
formatDateTime,
|
|
||||||
timeBetween,
|
|
||||||
timeSince,
|
|
||||||
toDateInputValue,
|
|
||||||
waitTimeColor,
|
|
||||||
} from "@/lib/time-utils";
|
|
||||||
|
|
||||||
export type DispatchEditFormState = {
|
|
||||||
status: string;
|
|
||||||
nteAmount: string;
|
|
||||||
scheduledDate: string;
|
|
||||||
completedDate: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DispatchEditFormProps = {
|
|
||||||
form: DispatchEditFormState;
|
|
||||||
onChange: (form: DispatchEditFormState) => void;
|
|
||||||
onSave: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isSaving: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchEditForm({
|
|
||||||
form,
|
|
||||||
onChange,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
isSaving,
|
|
||||||
}: DispatchEditFormProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Status"
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={form.status}
|
|
||||||
onChange={(event) => onChange({ ...form, status: event.target.value })}
|
|
||||||
>
|
|
||||||
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
|
||||||
<MenuItem key={status} value={status}>
|
|
||||||
{status}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
label="NTE Amount"
|
|
||||||
type="number"
|
|
||||||
size="small"
|
|
||||||
value={form.nteAmount}
|
|
||||||
onChange={(event) => onChange({ ...form, nteAmount: event.target.value })}
|
|
||||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Scheduled Date"
|
|
||||||
type="date"
|
|
||||||
size="small"
|
|
||||||
value={form.scheduledDate}
|
|
||||||
onChange={(event) => onChange({ ...form, scheduledDate: event.target.value })}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Completed Date"
|
|
||||||
type="date"
|
|
||||||
size="small"
|
|
||||||
value={form.completedDate}
|
|
||||||
onChange={(event) => onChange({ ...form, completedDate: event.target.value })}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
multiline
|
|
||||||
rows={3}
|
|
||||||
size="small"
|
|
||||||
className="sm:col-span-2"
|
|
||||||
value={form.description}
|
|
||||||
onChange={(event) => onChange({ ...form, description: event.target.value })}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
|
||||||
<Button variant="contained" onClick={onSave} disabled={isSaving}>
|
|
||||||
{isSaving ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onCancel}>Cancel</Button>
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DispatchDetailsViewProps = {
|
|
||||||
dispatch: DispatchDetail;
|
|
||||||
isLocked: boolean;
|
|
||||||
onEdit: () => void;
|
|
||||||
onCancelDispatch: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchDetailsView({
|
|
||||||
dispatch,
|
|
||||||
isLocked,
|
|
||||||
onEdit,
|
|
||||||
onCancelDispatch,
|
|
||||||
}: DispatchDetailsViewProps) {
|
|
||||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DetailField label="Status" value={dispatch.status} />
|
|
||||||
<DetailField
|
|
||||||
label="NTE"
|
|
||||||
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
|
||||||
/>
|
|
||||||
<DetailField label="Scheduled" value={toDateInputValue(dispatch.scheduledDate) || "—"} />
|
|
||||||
<DetailField label="Completed" value={toDateInputValue(dispatch.completedDate) || "—"} />
|
|
||||||
<DetailField
|
|
||||||
label="Dispatched"
|
|
||||||
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
|
||||||
/>
|
|
||||||
<DetailField
|
|
||||||
label="Accepted"
|
|
||||||
value={
|
|
||||||
dispatch.acknowledgedAt
|
|
||||||
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
|
||||||
: dispatch.status === "Sent"
|
|
||||||
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
highlight={dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined}
|
|
||||||
/>
|
|
||||||
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
|
||||||
{dispatch.description && (
|
|
||||||
<DetailField label="Description" value={dispatch.description} className="sm:col-span-2" />
|
|
||||||
)}
|
|
||||||
{!isLocked && (
|
|
||||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
|
||||||
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
|
|
||||||
Cancel Dispatch
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" onClick={onEdit}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DispatchDetailsGrid({ children }: { children: React.ReactNode }) {
|
|
||||||
return <Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</Box>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { Box, Button, Chip, Stack, Typography } from "@mui/material";
|
|
||||||
import type { DispatchSignoff } from "@/domain/work-orders/types/work-order";
|
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
type DispatchSignoffsSectionProps = {
|
|
||||||
signoffs: DispatchSignoff[];
|
|
||||||
isLocked: boolean;
|
|
||||||
onCollectSignature: (type: "Customer" | "Vendor") => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchSignoffsSection({
|
|
||||||
signoffs,
|
|
||||||
isLocked,
|
|
||||||
onCollectSignature,
|
|
||||||
}: DispatchSignoffsSectionProps) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
||||||
Sign-offs
|
|
||||||
</Typography>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
||||||
{(["Customer", "Vendor"] as const).map((type) => {
|
|
||||||
const signoff = signoffs.find((item) => item.signoffType === type);
|
|
||||||
return (
|
|
||||||
<Box key={type} className="flex-1 rounded border border-border p-3">
|
|
||||||
<Typography variant="subtitle2">{type}</Typography>
|
|
||||||
{signoff ? (
|
|
||||||
<Box className="mt-2">
|
|
||||||
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
|
||||||
<img
|
|
||||||
src={signoff.signature}
|
|
||||||
alt="Signature"
|
|
||||||
className="mb-2 max-h-20 max-w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{signoff.signatureMethod === "typed" && (
|
|
||||||
<Typography variant="h6" className="italic">
|
|
||||||
{signoff.signature}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{signoff.name}
|
|
||||||
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => onCollectSignature(type)}
|
|
||||||
disabled={isLocked}
|
|
||||||
>
|
|
||||||
{isLocked ? "Not signed" : "Collect Signature"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
{signoffs.length >= 2 && (
|
|
||||||
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
type DispatchUpliftsSectionProps = {
|
|
||||||
uplifts: UpliftRequest[];
|
|
||||||
isLocked: boolean;
|
|
||||||
onApprove: (uplift: UpliftRequest) => void;
|
|
||||||
onDeny: (uplift: UpliftRequest) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchUpliftsSection({
|
|
||||||
uplifts,
|
|
||||||
isLocked,
|
|
||||||
onApprove,
|
|
||||||
onDeny,
|
|
||||||
}: DispatchUpliftsSectionProps) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
||||||
NTE Uplift Requests
|
|
||||||
</Typography>
|
|
||||||
{uplifts.length === 0 ? (
|
|
||||||
<Typography color="text.secondary">No uplift requests.</Typography>
|
|
||||||
) : (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
{uplifts.map((uplift) => {
|
|
||||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
|
||||||
<Chip label={uplift.status} size="small" />
|
|
||||||
{uplift.requiredTier === 2 && (
|
|
||||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="body2">
|
|
||||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
|
||||||
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
|
||||||
{Number(uplift.delta).toFixed(2)})
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
{uplift.vendorReason && (
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
<strong>Reason:</strong> {uplift.vendorReason}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
|
||||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
|
||||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
|
||||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{uplift.status !== "Pending" &&
|
|
||||||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
|
||||||
<Box className="mt-1">
|
|
||||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{uplift.status} by {uplift.decidedByName || "—"}
|
|
||||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{uplift.decisionNote && (
|
|
||||||
<Typography variant="body2" className="mt-0.5">
|
|
||||||
<strong>Note:</strong> {uplift.decisionNote}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{uplift.status === "Pending" && (
|
|
||||||
<Stack direction="row" spacing={1} className="mt-2">
|
|
||||||
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="success"
|
|
||||||
variant="contained"
|
|
||||||
disabled={!canDecide}
|
|
||||||
onClick={() => onApprove(uplift)}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
disabled={!canDecide}
|
|
||||||
onClick={() => onDeny(uplift)}
|
|
||||||
>
|
|
||||||
Deny
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
|
|
||||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
|
||||||
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
|
||||||
import type { DispatchComment } from "@/domain/work-orders/types/work-order";
|
|
||||||
|
|
||||||
type DispatchVendorCommunicationProps = {
|
|
||||||
comments: DispatchComment[];
|
|
||||||
commentText: string;
|
|
||||||
sendEmail: boolean;
|
|
||||||
isLocked: boolean;
|
|
||||||
isPending: boolean;
|
|
||||||
onCommentTextChange: (value: string) => void;
|
|
||||||
onSendEmailChange: (value: boolean) => void;
|
|
||||||
onSubmit: (event: React.FormEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchVendorCommunication({
|
|
||||||
comments,
|
|
||||||
commentText,
|
|
||||||
sendEmail,
|
|
||||||
isLocked,
|
|
||||||
isPending,
|
|
||||||
onCommentTextChange,
|
|
||||||
onSendEmailChange,
|
|
||||||
onSubmit,
|
|
||||||
}: DispatchVendorCommunicationProps) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<span className="mb-2 block text-base font-semibold">Vendor Communication</span>
|
|
||||||
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
|
||||||
<CommentTimeline
|
|
||||||
items={mapDispatchCommentsToTimeline(comments)}
|
|
||||||
emptyMessage="No messages yet."
|
|
||||||
maxHeightClass="max-h-none"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box component="form" onSubmit={onSubmit}>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
value={commentText}
|
|
||||||
onChange={(event) => onCommentTextChange(event.target.value)}
|
|
||||||
placeholder="Message vendor..."
|
|
||||||
fullWidth
|
|
||||||
disabled={isLocked || isPending}
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
className="mt-2"
|
|
||||||
sx={{ justifyContent: "space-between", alignItems: "center" }}
|
|
||||||
>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={sendEmail}
|
|
||||||
onChange={(event) => onSendEmailChange(event.target.checked)}
|
|
||||||
disabled={isLocked}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Send email"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
disabled={isLocked || isPending || !commentText.trim()}
|
|
||||||
>
|
|
||||||
{isPending ? "Sending..." : "Send"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { Alert, Button, Stack, Typography } from "@mui/material";
|
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
type DispatchVerifyFooterProps = {
|
|
||||||
verifiedAt: string;
|
|
||||||
verifiedBy: string;
|
|
||||||
canVerify: boolean;
|
|
||||||
missing: string[];
|
|
||||||
isPending: boolean;
|
|
||||||
onVerify: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DispatchVerifyFooter({
|
|
||||||
verifiedAt,
|
|
||||||
verifiedBy,
|
|
||||||
canVerify,
|
|
||||||
missing,
|
|
||||||
isPending,
|
|
||||||
onVerify,
|
|
||||||
}: DispatchVerifyFooterProps) {
|
|
||||||
if (verifiedAt) {
|
|
||||||
return (
|
|
||||||
<Alert severity="success">
|
|
||||||
Verified by {verifiedBy} on {formatDateTime(verifiedAt)}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
{missing.length > 0 && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Missing: {missing.join(", ")}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="success"
|
|
||||||
disabled={!canVerify || isPending}
|
|
||||||
onClick={onVerify}
|
|
||||||
>
|
|
||||||
Verify & Close
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material";
|
|
||||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
|
||||||
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
|
|
||||||
import { DispatchChecklistSection } from "./dispatch-checklist-section";
|
|
||||||
import { DispatchDetailHeader } from "./dispatch-detail-header";
|
|
||||||
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
|
|
||||||
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
|
|
||||||
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
|
|
||||||
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
|
|
||||||
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
|
|
||||||
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
|
|
||||||
|
|
||||||
interface DispatchDetailModalProps {
|
|
||||||
dispatchId: string | number;
|
|
||||||
workOrderId: string | number;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdated?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DispatchDetailModal({
|
|
||||||
dispatchId,
|
|
||||||
workOrderId,
|
|
||||||
onClose,
|
|
||||||
}: DispatchDetailModalProps) {
|
|
||||||
const controller = useDispatchDetailController(dispatchId, workOrderId);
|
|
||||||
|
|
||||||
if (controller.isLoading) {
|
|
||||||
return (
|
|
||||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
|
||||||
<DialogContent className="flex justify-center py-12">
|
|
||||||
<CircularProgress />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controller.dispatch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dispatch } = controller;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
|
||||||
<DialogTitle>
|
|
||||||
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogContent className="flex flex-col gap-6">
|
|
||||||
<DispatchDetailsGrid>
|
|
||||||
{controller.editing ? (
|
|
||||||
<DispatchEditForm
|
|
||||||
form={controller.form}
|
|
||||||
onChange={controller.setForm}
|
|
||||||
onSave={controller.handleSave}
|
|
||||||
onCancel={() => controller.setEditing(false)}
|
|
||||||
isSaving={controller.updateDispatchMutation.isPending}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DispatchDetailsView
|
|
||||||
dispatch={dispatch}
|
|
||||||
isLocked={controller.isLocked}
|
|
||||||
onEdit={controller.beginEditing}
|
|
||||||
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DispatchDetailsGrid>
|
|
||||||
|
|
||||||
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
|
|
||||||
<DispatchChecklistSection
|
|
||||||
items={dispatch.checklistItems}
|
|
||||||
isLocked={controller.isLocked}
|
|
||||||
isUpdating={controller.updateChecklistMutation.isPending}
|
|
||||||
userName={controller.userName}
|
|
||||||
newItem={controller.newChecklistItem}
|
|
||||||
onNewItemChange={controller.setNewChecklistItem}
|
|
||||||
onToggleItem={(itemId, isCompleted) =>
|
|
||||||
controller.updateChecklistMutation.mutate({
|
|
||||||
itemId,
|
|
||||||
isCompleted,
|
|
||||||
completedBy: controller.userName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onAddItem={(text) =>
|
|
||||||
controller.addChecklistMutation.mutate(text, {
|
|
||||||
onSuccess: () => controller.setNewChecklistItem(""),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DispatchSignoffsSection
|
|
||||||
signoffs={dispatch.signoffs}
|
|
||||||
isLocked={controller.isLocked}
|
|
||||||
onCollectSignature={controller.setSigCaptureType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DispatchUpliftsSection
|
|
||||||
uplifts={controller.uplifts}
|
|
||||||
isLocked={controller.isLocked}
|
|
||||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
|
||||||
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DispatchVendorCommunication
|
|
||||||
comments={dispatch.comments}
|
|
||||||
commentText={controller.commentText}
|
|
||||||
sendEmail={controller.sendEmail}
|
|
||||||
isLocked={controller.isLocked}
|
|
||||||
isPending={controller.addCommentMutation.isPending}
|
|
||||||
onCommentTextChange={controller.setCommentText}
|
|
||||||
onSendEmailChange={controller.setSendEmail}
|
|
||||||
onSubmit={controller.handleComment}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DispatchVerifyFooter
|
|
||||||
verifiedAt={dispatch.verifiedAt}
|
|
||||||
verifiedBy={dispatch.verifiedBy}
|
|
||||||
canVerify={controller.verifyState.canVerify}
|
|
||||||
missing={controller.verifyState.missing}
|
|
||||||
isPending={controller.verifyMutation.isPending}
|
|
||||||
onVerify={() => controller.verifyMutation.mutate(undefined)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{controller.sigCaptureType && (
|
|
||||||
<SignatureCapture
|
|
||||||
title={`${controller.sigCaptureType} Sign-off`}
|
|
||||||
onClose={() => controller.setSigCaptureType(null)}
|
|
||||||
onSave={(signature, method, name) => {
|
|
||||||
controller.addSignoffMutation.mutate(
|
|
||||||
{
|
|
||||||
signoffType: controller.sigCaptureType!,
|
|
||||||
name,
|
|
||||||
signature,
|
|
||||||
signatureMethod: method,
|
|
||||||
},
|
|
||||||
{ onSuccess: () => controller.setSigCaptureType(null) },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{controller.upliftAction && (
|
|
||||||
<UpliftDecisionDialog
|
|
||||||
open
|
|
||||||
type={controller.upliftAction.type}
|
|
||||||
requestedNTE={controller.upliftAction.uplift.requestedNTE}
|
|
||||||
currentNTE={controller.upliftAction.uplift.currentNTE}
|
|
||||||
onClose={() => controller.setUpliftAction(null)}
|
|
||||||
onConfirm={controller.handleUpliftConfirm}
|
|
||||||
isPending={
|
|
||||||
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
type UpliftDecisionDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
type: "approve" | "deny";
|
|
||||||
requestedNTE: number;
|
|
||||||
currentNTE: number | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (note: string) => void;
|
|
||||||
isPending?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UpliftDecisionDialog({
|
|
||||||
open,
|
|
||||||
type,
|
|
||||||
requestedNTE,
|
|
||||||
currentNTE,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
isPending = false,
|
|
||||||
}: UpliftDecisionDialogProps) {
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setNote("");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onConfirm(note.trim());
|
|
||||||
setNote("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
{type === "approve" && (
|
|
||||||
<Typography variant="body2" className="mt-1 mb-3">
|
|
||||||
${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$
|
|
||||||
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
className="mt-2"
|
|
||||||
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
|
|
||||||
value={note}
|
|
||||||
onChange={(event) => setNote(event.target.value)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color={type === "approve" ? "success" : "error"}
|
|
||||||
disabled={type === "deny" ? !note.trim() || isPending : isPending}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -128,7 +128,7 @@ export function DispatcherFilter({
|
||||||
onClick={() => onChange(new Set())}
|
onClick={() => onChange(new Set())}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{meId && (
|
{meId != null && (
|
||||||
<CheckboxMenuItem
|
<CheckboxMenuItem
|
||||||
checked={meOnly}
|
checked={meOnly}
|
||||||
label="My WOs only"
|
label="My WOs only"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import { Button, Popover } from "@mui/material";
|
import { Button, Popover } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
calcDuration,
|
calcDuration,
|
||||||
fmt12,
|
fmt12,
|
||||||
|
|
@ -71,9 +72,19 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 border-b border-border">
|
<div className="grid grid-cols-2 border-b border-border">
|
||||||
<div className="border-r border-border">
|
<div className="border-r border-border">
|
||||||
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-3 pb-1 pt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Start
|
Start
|
||||||
</p>
|
</Text>
|
||||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||||
{TIME_SLOTS.map((t) => (
|
{TIME_SLOTS.map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -94,9 +105,19 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-3 pb-1 pt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
End <span className="font-normal normal-case">(optional)</span>
|
End <span className="font-normal normal-case">(optional)</span>
|
||||||
</p>
|
</Text>
|
||||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||||
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
|
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ type CompDocCellProps = {
|
||||||
status: CompletionDocStatus;
|
status: CompletionDocStatus;
|
||||||
woType: WOType | "";
|
woType: WOType | "";
|
||||||
mediaCount?: number;
|
mediaCount?: number;
|
||||||
onOpen: () => void;
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
|
onOpen?: () => void;
|
||||||
onOpenMedia: () => void;
|
onOpenMedia: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,6 +39,10 @@ export function CompDocCell({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!onOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const generated = status === "Yes";
|
const generated = status === "Yes";
|
||||||
const notRequired = status === "NN";
|
const notRequired = status === "NN";
|
||||||
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export function DateCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||||
>
|
>
|
||||||
{allowWeekMode && (
|
{Boolean(allowWeekMode) && (
|
||||||
<div className="mb-2.5 flex items-center gap-1.5">
|
<div className="mb-2.5 flex items-center gap-1.5">
|
||||||
{(["week", "date"] as const).map((m) => {
|
{(["week", "date"] as const).map((m) => {
|
||||||
const active = cellMode === m;
|
const active = cellMode === m;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function SiteCell({
|
||||||
>
|
>
|
||||||
<HighlightText text={value} q={q} />
|
<HighlightText text={value} q={q} />
|
||||||
</span>
|
</span>
|
||||||
{location && (
|
{Boolean(location) && (
|
||||||
<>
|
<>
|
||||||
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
|
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
|
||||||
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
|
@ -144,7 +145,9 @@ export function SiteDialog({
|
||||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||||
Site
|
Site
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p className="sr-only">Select a site and edit its point of contact</p>
|
<Text as="p" className="sr-only">
|
||||||
|
Select a site and edit its point of contact
|
||||||
|
</Text>
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -168,7 +171,7 @@ export function SiteDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{locationDetail && locId && (
|
{locationDetail != null && Boolean(locId) && (
|
||||||
<div
|
<div
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -189,11 +192,11 @@ export function SiteDialog({
|
||||||
.join(", ") || "—"
|
.join(", ") || "—"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
|
{locationDetail.phone != null && <Detail icon={Phone} text={locationDetail.phone} />}
|
||||||
{locationDetail.contactEmail && (
|
{locationDetail.contactEmail != null && (
|
||||||
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
||||||
)}
|
)}
|
||||||
{locationDetail.address && (
|
{Boolean(locationDetail.address) && (
|
||||||
<a
|
<a
|
||||||
href={buildMapsUrl(locationDetail)}
|
href={buildMapsUrl(locationDetail)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -209,9 +212,13 @@ export function SiteDialog({
|
||||||
|
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
<p className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mb-2"
|
||||||
|
style={{ fontSize: 11, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
Auto-filled from records — edit if outdated
|
Auto-filled from records — edit if outdated
|
||||||
</p>
|
</Text>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
value={pn}
|
value={pn}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
||||||
import { AlertTriangle, Check } from "lucide-react";
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
|
|
@ -74,10 +75,17 @@ export function StatusCell({
|
||||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] leading-snug text-[var(--status-pending-text)]">
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.375,
|
||||||
|
color: "var(--status-pending-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
This work order is past due. A new date must be scheduled before the status can be
|
This work order is past due. A new date must be scheduled before the status can be
|
||||||
updated.
|
updated.
|
||||||
</p>
|
</Text>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
|
||||||
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
|
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
|
||||||
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
|
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
|
||||||
</div>
|
</div>
|
||||||
{tech && company && (
|
{Boolean(tech && company) && (
|
||||||
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
|
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
|
||||||
<HighlightText text={company} q={q} />
|
<HighlightText text={company} q={q} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue