Merge remote-tracking branch 'origin/dev' into codex/pr43-dev-integration

# Conflicts:
#	src/app/(protected)/workorders/index.tsx
#	src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
This commit is contained in:
Alexandre Brandizzi 2026-07-24 18:13:54 -03:00
commit caecc05b68
130 changed files with 8564 additions and 1232 deletions

View file

@ -11,6 +11,33 @@ permissions:
jobs:
ci:
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
with:
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
View 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).

View 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
View 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`.

View file

@ -95,15 +95,17 @@ 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) |
| 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
@ -132,7 +134,13 @@ 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.
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`)

View 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.

View 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.

508
e2e/vendors/vendor-operations.spec.ts vendored Normal file
View 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
View 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();
});
});

View 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",
});
});

View 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;

View 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;

View file

@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
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 = [
"src/pages/**",
"src/app/store.js",
@ -54,6 +64,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@ -62,6 +73,23 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@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.",
},
],
},
},
{

View file

@ -11,10 +11,12 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"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"
},
"lint-staged": {

View file

@ -10,6 +10,10 @@ export default defineConfig({
use: {
// Port 4173 isolates Playwright from the app's normal port 3000 server.
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",
},
projects: [

View 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."
}
]
}

View 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();

View file

@ -109,6 +109,8 @@ export const API_PATHS = {
update: "Vendor/Update",
delete: "Vendor/Delete",
dropdown: "Vendor/Dropdown",
facets: "Vendor/facets",
deactivationImpact: "deactivation-impact",
},
calendar: {
getEventsForCalendar: "Calendar/GetEventsForCalendar",

View file

@ -1,5 +1,6 @@
import type { ComponentPropsWithoutRef } from "react";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
{subtitle}
</p>
)}
<Text variant="title" className="m-0 text-center font-bold text-foreground">
{title}
</Text>
<Text
variant="description"
tone="muted"
when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal"
>
{subtitle}
</Text>
</div>
);
}

View file

@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn}
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>
<Button

View file

@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -37,9 +38,13 @@ export default function LoginPage() {
isLoggingIn={isLoggingIn}
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.
</p>
</Text>
</AuthPageShell>
);
}

View file

@ -110,7 +110,7 @@ export default function AccountFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load account"}
</Alert>

View file

@ -118,7 +118,7 @@ export default function AccountsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"}
</Alert>

View file

@ -91,7 +91,7 @@ export default function AssetFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
{(error || optionsError) && (
{Boolean(error || optionsError) && (
<Alert severity="error">
{error instanceof Error
? error.message

View file

@ -116,7 +116,7 @@ export default function AssetsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"}
</Alert>

View file

@ -120,7 +120,7 @@ export default function EventFormPage() {
<Stack spacing={3}>
<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)}>
<Stack spacing={3}>

View file

@ -127,7 +127,7 @@ export default function ContactFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contact"}
</Alert>

View file

@ -103,7 +103,7 @@ export default function ContactsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contacts"}
</Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{description && (
{Boolean(description) && (
<Typography
variant="body2"
sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>

View file

@ -83,7 +83,7 @@ function SelectField({
</MenuItem>
))}
</Select>
{helperText && (
{Boolean(helperText) && (
<Typography variant="caption" color="error" className="mt-1">
{helperText}
</Typography>
@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employee"}
</Alert>

View file

@ -124,7 +124,7 @@ export default function EmployeesListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employees"}
</Alert>

View file

@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-up"}
</Alert>

View file

@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{description && (
{Boolean(description) && (
<Typography
variant="body2"
sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>

View file

@ -114,7 +114,7 @@ export default function LocationFormPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>

View file

@ -118,7 +118,7 @@ export default function LocationsListPage() {
</Button>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>

View 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>
);
}

View file

@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
{optionsError && (
{Boolean(error) && <Alert severity="error">Failed to load PM schedule</Alert>}
{Boolean(optionsError) && (
<Alert severity="error">
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
</Alert>

View file

@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
)}
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load PM schedules"}
</Alert>

View file

@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
alignItems: "stretch",
}}
>
{parentCategory && (
{Boolean(parentCategory) && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select
@ -201,7 +201,7 @@ export default function DropdownOptionsPage() {
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"}
</Alert>
@ -262,7 +262,7 @@ export default function DropdownOptionsPage() {
<Typography variant="body2" className="truncate">
{item.value}
</Typography>
{item.parentValue && (
{Boolean(item.parentValue) && (
<Chip size="small" label={item.parentValue} variant="outlined" />
)}
</Stack>

View file

@ -126,7 +126,7 @@ export default function TaskTemplatesPage() {
Create reusable checklists for work order dispatches
</Typography>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
</Alert>

View file

@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
</FormControl>
</Stack>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{row.workOrderId && (
{Boolean(row.workOrderId) && (
<Button
size="small"
variant="outlined"

View file

@ -127,7 +127,7 @@ export default function VendorPoViewPage() {
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
{vendor != null && Boolean(vendor.companyName) && (
<Typography variant="body2" color="text.secondary">
· {vendor.companyName}
</Typography>
@ -177,7 +177,7 @@ export default function VendorPoViewPage() {
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{dispatch.description && (
{Boolean(dispatch.description) && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
@ -189,7 +189,7 @@ export default function VendorPoViewPage() {
)}
</Paper>
{dispatch.primaryLocation && (
{dispatch.primaryLocation != null && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
@ -277,7 +277,7 @@ export default function VendorPoViewPage() {
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{u.vendorReason && (
{Boolean(u.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>

View file

@ -199,7 +199,7 @@ export default function VendorPosListPage() {
Clear filters
</Button>
</Stack>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,13 @@
export interface VendorFilters {
companies: string[];
trades: string[];
locations: string[];
jobBuckets: string[];
}
export const emptyVendorFilters: VendorFilters = {
companies: [],
trades: [],
locations: [],
jobBuckets: [],
};

View 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>
);
}

View file

@ -6,34 +6,40 @@ import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
FormControlLabel,
FormGroup,
FormLabel,
Paper,
Stack,
TextField,
Switch,
Typography,
} from "@mui/material";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
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 { 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 { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
const defaultValues: VendorFormValues = {
companyName: "",
contactName: "",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
export default function VendorFormPage() {
@ -42,21 +48,25 @@ export default function VendorFormPage() {
const isEdit = Boolean(id);
const { data: vendor, isLoading, error } = useVendorDetail(id);
const { data: facets } = useVendorFacets();
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
const createVendor = useCreateVendor();
const updateVendor = useUpdateVendor();
const {
register,
handleSubmit,
reset,
setValue,
control,
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
mode: "onChange",
});
const currentValues = useWatch({ control });
const isFormValid = vendorSchema.safeParse(currentValues).success;
useEffect(() => {
if (vendor && isEdit) {
@ -65,37 +75,44 @@ export default function VendorFormPage() {
contactName: vendor.contactName,
email: vendor.email,
phone: vendor.phone,
companyPhone: vendor.companyPhone,
googleMapsUrl: vendor.googleMapsUrl,
notes: vendor.notes,
totalJobs: vendor.totalJobs,
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",
});
}
}, [vendor, isEdit, reset]);
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
const selectedTrades = (tradeSpecialties || "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
const toggleTrade = (tradeValue: string) => {
const updated = selectedTrades.includes(tradeValue)
? selectedTrades.filter((trade) => trade !== tradeValue)
: [...selectedTrades, tradeValue];
setValue("tradeSpecialties", updated.join(", "));
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 isSaving = createVendor.isPending || updateVendor.isPending;
const onSubmit = (values: VendorFormValues) => {
if (isEdit && id) {
updateVendor.mutate({ id, values });
updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") });
return;
}
createVendor.mutate(values);
createVendor.mutate(values, { onSuccess: () => navigate("/vendors") });
};
if (isEdit && isLoading) {
@ -112,7 +129,7 @@ export default function VendorFormPage() {
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor"}
</Alert>
@ -120,73 +137,27 @@ export default function VendorFormPage() {
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<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
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="Active"
control={<Switch checked={Boolean(field.value)} onChange={field.onChange} />}
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
direction="row"
@ -198,7 +169,7 @@ export default function VendorFormPage() {
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>

View file

@ -1,9 +1,11 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import FilterListIcon from "@mui/icons-material/FilterList";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Alert,
Avatar,
Box,
Button,
Chip,
@ -14,8 +16,12 @@ import {
DialogContentText,
DialogTitle,
IconButton,
Link,
List,
ListItem,
Paper,
Stack,
Tab,
Table,
TableBody,
TableCell,
@ -23,39 +29,193 @@ import {
TableHead,
TablePagination,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} 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 { 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 { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
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() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
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 [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 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(
() => ({ 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: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 });
const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 });
const rows = data?.items ?? [];
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 = () => {
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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
@ -66,23 +226,12 @@ export default function VendorsListPage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors
Vendors / Technicians
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} technicians · {activeCount} active
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
@ -90,9 +239,7 @@ export default function VendorsListPage() {
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
@ -101,133 +248,362 @@ export default function VendorsListPage() {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search vendors..."
className="min-w-sidebar"
placeholder="Search name, company, trade..."
className="min-w-[260px]"
/>
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
+ New Vendor
<Button
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>
</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">
{error instanceof Error ? error.message : "Failed to load vendors"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableHead>
<TableRow>
<TableCell className="font-semibold">Company</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Email</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Trades</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
{column}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
{showLoadingRow ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
>
{row.companyName || "—"}
</Button>
</TableCell>
<TableCell>{row.contactName || "—"}</TableCell>
<TableCell>{row.email || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
rows.map((row) => {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
return (
<TableRow
key={String(row.id)}
hover
tabIndex={0}
role="button"
aria-label={`Open vendor details for ${row.companyName}`}
sx={{ cursor: "pointer" }}
onClick={() => openDetail(row, "view")}
onKeyDown={handleRowKeyDown(row)}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
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"
aria-label="Edit vendor"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
label={row.isActive ? "Active" : "Inactive"}
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" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete vendor"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={stopAnd(() => openDetail(row, "edit"))}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={stopAnd(() => openDeactivation(row))}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
}}
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>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.companyName}&quot;?
</DialogContentText>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{deleteTarget?.companyName}&quot;? 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>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteVendor.isPending}
disabled={
deleteVendor.isPending ||
isLoadingImpact ||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
Boolean(impactError)
}
>
{deleteVendor.isPending ? "Deleting..." : "Delete"}
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
</Button>
</DialogActions>
</Dialog>

123
src/app/(protected)/vendors/insights.tsx vendored Normal file
View 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>
);
}

View 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>
);
}

View file

@ -10,6 +10,10 @@ import {
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
Menu,
MenuItem,
@ -36,8 +40,11 @@ import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useUpdateWorkOrderVendor,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
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<
@ -53,6 +60,31 @@ const STATUS_COLORS: Record<
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(
() =>
@ -167,6 +199,7 @@ export default function WorkOrderViewPage() {
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
@ -174,6 +207,9 @@ export default function WorkOrderViewPage() {
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"),
@ -200,6 +236,74 @@ export default function WorkOrderViewPage() {
);
};
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) {
return (
<Box className="flex justify-center p-12">
@ -426,6 +530,7 @@ export default function WorkOrderViewPage() {
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -465,6 +570,17 @@ export default function WorkOrderViewPage() {
"—"
)}
</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
);
})}
@ -542,7 +658,7 @@ export default function WorkOrderViewPage() {
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId && (
{dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
@ -550,6 +666,115 @@ export default function WorkOrderViewPage() {
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>
);
}

View file

@ -242,7 +242,7 @@ export function DispatchCreateModal({
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{vendor.tradeSpecialties && (
{Boolean(vendor.tradeSpecialties) && (
<Typography
variant="caption"
sx={{
@ -253,7 +253,7 @@ export function DispatchCreateModal({
{vendor.tradeSpecialties}
</Typography>
)}
{vendor.address && (
{Boolean(vendor.address) && (
<Typography
variant="caption"
sx={{

View file

@ -331,7 +331,7 @@ export function DispatchDetailModal({
}
/>
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
{dispatch.description && (
{Boolean(dispatch.description) && (
<DetailField
label="Description"
value={dispatch.description}
@ -448,7 +448,7 @@ export function DispatchDetailModal({
<Typography variant="subtitle2">{type}</Typography>
{signoff ? (
<Box className="mt-2">
{signoff.signatureMethod === "drawn" && signoff.signature && (
{signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
<img
src={signoff.signature}
alt="Signature"
@ -527,12 +527,12 @@ export function DispatchDetailModal({
{Number(uplift.delta).toFixed(2)})
</Typography>
</Stack>
{uplift.vendorReason && (
{Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{(uplift.requestedByVendorName || uplift.requestedAt) && (
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography
variant="body2"
className="mt-1"
@ -545,9 +545,11 @@ export function DispatchDetailModal({
</Typography>
)}
{uplift.status !== "Pending" &&
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
Boolean(
uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
) && (
<Box className="mt-1">
{(uplift.decidedByName || uplift.decidedAt) && (
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography
variant="body2"
sx={{
@ -558,7 +560,7 @@ export function DispatchDetailModal({
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
)}
{uplift.decisionNote && (
{Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
@ -720,7 +722,7 @@ export function DispatchDetailModal({
)}
</DialogContent>
</Dialog>
{sigCaptureType && (
{sigCaptureType != null && (
<SignatureCapture
title={`${sigCaptureType} Sign-off`}
onClose={() => setSigCaptureType(null)}

View file

@ -128,7 +128,7 @@ export function DispatcherFilter({
onClick={() => onChange(new Set())}
/>
{meId && (
{meId != null && (
<CheckboxMenuItem
checked={meOnly}
label="My WOs only"

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Clock } from "lucide-react";
import { Button, Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
calcDuration,
fmt12,
@ -71,9 +72,19 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
>
<div className="grid grid-cols-2 border-b 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
</p>
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
@ -94,9 +105,19 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
</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>
</p>
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
<button

View file

@ -92,7 +92,7 @@ export function DateCell({
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
>
{allowWeekMode && (
{Boolean(allowWeekMode) && (
<div className="mb-2.5 flex items-center gap-1.5">
{(["week", "date"] as const).map((m) => {
const active = cellMode === m;

View file

@ -49,7 +49,7 @@ export function SiteCell({
>
<HighlightText text={value} q={q} />
</span>
{location && (
{Boolean(location) && (
<>
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
<span className="truncate text-xs text-[var(--muted-foreground)]">

View file

@ -8,6 +8,7 @@ import {
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { Location } from "@/domain/locations/types/location";
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)" }}>
Site
</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>
@ -168,7 +171,7 @@ export function SiteDialog({
/>
</div>
{locationDetail && locId && (
{locationDetail != null && Boolean(locId) && (
<div
className="space-y-1.5 rounded-lg p-3"
style={{
@ -189,11 +192,11 @@ export function SiteDialog({
.join(", ") || "—"
}
/>
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
{locationDetail.contactEmail && (
{locationDetail.phone != null && <Detail icon={Phone} text={locationDetail.phone} />}
{locationDetail.contactEmail != null && (
<Detail icon={Mail} text={locationDetail.contactEmail} />
)}
{locationDetail.address && (
{Boolean(locationDetail.address) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
@ -209,9 +212,13 @@ export function SiteDialog({
<div className="pt-1">
<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
</p>
</Text>
<div className="space-y-2">
<input
value={pn}

View file

@ -3,6 +3,7 @@ import { useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
ALL_WIZARD_STATUSES,
@ -74,10 +75,17 @@ export function StatusCell({
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<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
updated.
</p>
</Text>
<button
type="button"

View file

@ -32,7 +32,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
</div>
{tech && company && (
{Boolean(tech && company) && (
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
<HighlightText text={company} q={q} />
</div>

View file

@ -8,6 +8,7 @@ import {
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
@ -165,7 +166,9 @@ export function VendorDialog({
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Technician
</DialogTitle>
<p className="sr-only">Search and select the technician</p>
<Text as="p" className="sr-only">
Search and select the technician
</Text>
<div className="mt-3 space-y-3">
<div>
@ -189,7 +192,7 @@ export function VendorDialog({
/>
</div>
{t && (
{Boolean(t) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
@ -202,7 +205,7 @@ export function VendorDialog({
</div>
)}
{t && (
{Boolean(t) && (
<div>
<FieldLabel>Company</FieldLabel>
<div
@ -215,8 +218,8 @@ export function VendorDialog({
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{resolvedCompany || "—"}
</div>
{companyEmail && <Detail icon={Mail} text={companyEmail} />}
{selectedVendor?.address && (
{Boolean(companyEmail) && <Detail icon={Mail} text={companyEmail} />}
{selectedVendor != null && selectedVendor.address != null && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
target="_blank"

View file

@ -61,7 +61,7 @@ export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) {
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/row:opacity-100">
{mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")}
{onEdit ? mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)") : null}
{onEdit != null && mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")}
</div>
);
}

View file

@ -108,7 +108,7 @@ export function WoTableSectionHeader({
>
{count} WO{count !== 1 ? "s" : ""}
</span>
{isToday && (
{Boolean(isToday) && (
<span
className="uppercase"
style={{

View file

@ -1,5 +1,6 @@
import { Fragment, useMemo, useState } from "react";
import { CircularProgress } from "@mui/material";
import { Text } from "@/components/ui/text";
import { SearchX } from "lucide-react";
import type {
LocationOption,
@ -64,8 +65,10 @@ function WoTableEmptyState({
style={{ gap: 14, padding: 48 }}
>
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
<p style={{ fontSize: 14, color: "var(--muted-foreground)" }}>{message}</p>
{onAction && (
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
{message}
</Text>
{Boolean(onAction) && (
<button
type="button"
onClick={onAction}

View file

@ -31,7 +31,7 @@ export function WorkOrdersListHeader({
aria-label="Search work orders"
/>
{onOpenAdvancedFilters && (
{Boolean(onOpenAdvancedFilters) && (
<ToolbarButton
variant="outline"
icon={<SlidersHorizontal size={13} />}
@ -44,7 +44,7 @@ export function WorkOrdersListHeader({
</ToolbarButton>
)}
{onNewWorkOrder && (
{Boolean(onNewWorkOrder) && (
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
New WO
</ToolbarButton>

View file

@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
@ -43,7 +44,9 @@ export function MediaUploadModal({
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<p className="sr-only">Upload photos and videos for this work order.</p>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />

View file

@ -0,0 +1,153 @@
import {
Alert,
Autocomplete,
Box,
Chip,
FormControlLabel,
Stack,
Switch,
TextField,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorAvailability } from "@/domain/vendor-operations/types/vendor-operations";
function SectionTitle({ children }: { children: string }) {
return (
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
{children}
</Text>
);
}
type VendorAndTimeSectionProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
availableVendors: VendorAvailability[];
isLoadingVendors: boolean;
selectedVendorId: number | null;
onSelectVendor: (id: number | null) => void;
estimatedArrivalAt: string;
onEtaChange: (value: string) => void;
etaManualOverride: boolean;
onOverrideChange: (value: boolean) => void;
scheduledStartLocal: string;
onScheduledStartChange: (value: string) => void;
scheduledEndLocal: string;
onScheduledEndChange: (value: string) => void;
scheduleBoundsError: string | null;
};
export function VendorAndTimeSection({
containerRef,
availableVendors,
isLoadingVendors,
selectedVendorId,
onSelectVendor,
estimatedArrivalAt,
onEtaChange,
etaManualOverride,
onOverrideChange,
scheduledStartLocal,
onScheduledStartChange,
scheduledEndLocal,
onScheduledEndChange,
scheduleBoundsError,
}: VendorAndTimeSectionProps) {
const hasBoundsError = Boolean(scheduleBoundsError);
return (
<Box ref={containerRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
<SectionTitle>Vendor & Time</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
label="Appointment start"
type="datetime-local"
value={scheduledStartLocal}
onChange={(event) => onScheduledStartChange(event.target.value)}
error={hasBoundsError}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
<TextField
label="Appointment end"
type="datetime-local"
value={scheduledEndLocal}
onChange={(event) => onScheduledEndChange(event.target.value)}
error={hasBoundsError}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
</Stack>
<Text variant="error" when={hasBoundsError} className="mt-1">
{scheduleBoundsError}
</Text>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-2">
<Autocomplete
options={availableVendors}
loading={isLoadingVendors}
getOptionLabel={(option) =>
`${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}`
}
value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null}
onChange={(_event, option) => onSelectVendor(option?.id ?? null)}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Stack className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text as="span">
{option.companyName}
{option.contactName ? ` · ${option.contactName}` : ""}
</Text>
{option.isPreferred && <Chip label="Preferred" color="primary" size="small" />}
<Chip
label={option.availabilityStatus}
color={
option.availabilityStatus === "Available"
? "success"
: option.availabilityStatus === "Unavailable"
? "error"
: "default"
}
size="small"
/>
</Stack>
{option.conflicts.length > 0 && (
<Text variant="caption" tone="error">
Conflicts with{" "}
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
</Text>
)}
</Stack>
</li>
)}
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
className="flex-1"
/>
<TextField
label="Assignment ETA"
type="datetime-local"
value={estimatedArrivalAt}
onChange={(event) => onEtaChange(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
<FormControlLabel
control={
<Switch
checked={etaManualOverride}
onChange={(event) => onOverrideChange(event.target.checked)}
/>
}
label="Manual ETA override"
/>
</Stack>
{selectedVendorId != null &&
availableVendors.find((vendor) => vendor.id === selectedVendorId)?.availabilityStatus ===
"Unknown" && (
<Alert severity="warning" className="mt-2">
<Text as="span">
Availability is unknown. This selection is not treated as confirmed availability.
</Text>
</Alert>
)}
</Box>
);
}

View file

@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { Text } from "@/components/ui/text";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption {
@ -130,12 +131,13 @@ export function WizardFieldSelect({
)}
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{filtered.length === 0 ? (
<p
className="px-2 py-2 text-center text-[12px]"
style={{ color: "var(--color-text-muted)" }}
<Text
as="p"
className="px-2 py-2 text-center"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
No matches
</p>
</Text>
) : (
filtered.map((o) => (
<button

View file

@ -92,8 +92,8 @@ export function WizLabel({
>
{leading != null && <span className="flex shrink-0 items-center">{leading}</span>}
<span className="shrink-0">{children}</span>
{required && <span style={{ color: "var(--destructive)" }}>*</span>}
{optional && <WizOptionalTag />}
{Boolean(required) && <span style={{ color: "var(--destructive)" }}>*</span>}
{Boolean(optional) && <WizOptionalTag />}
</label>
);
}
@ -129,9 +129,9 @@ export function WizLabelRow({
style={WIZ_LABEL_STYLE}
>
<span className="shrink-0">{children}</span>
{optional && <WizOptionalTag />}
{Boolean(optional) && <WizOptionalTag />}
</span>
{onClear && (
{Boolean(onClear) && (
<button
type="button"
onClick={onClear}

View file

@ -0,0 +1,319 @@
import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material";
import { Controller } from "react-hook-form";
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
import { Text } from "@/components/ui/text";
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import {
WORK_ORDER_PRIORITY_OPTIONS,
WORK_ORDER_STATUS_OPTIONS,
type LocationOption,
} from "@/domain/work-orders/types/work-order";
import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common";
function SectionTitle({ children }: { children: string }) {
return (
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
{children}
</Text>
);
}
type FormFieldHandlers = {
register: UseFormRegister<WorkOrderFormSchemaValues>;
control: Control<WorkOrderFormSchemaValues>;
errors: FieldErrors<WorkOrderFormSchemaValues>;
};
type WorkOrderDetailsFieldsProps = FormFieldHandlers & {
locationOptions: LocationOption[];
isLoadingLocations: boolean;
};
export function WorkOrderDetailsFields({
register,
control,
errors,
locationOptions,
isLoadingLocations,
}: WorkOrderDetailsFieldsProps) {
return (
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("workOrderNumber")}
label="Customer WO"
placeholder="Customer work order number"
fullWidth
/>
<TextField
{...register("title")}
label="Title"
required
error={Boolean(errors.title)}
helperText={errors.title?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
<Controller
name="locationId"
control={control}
render={({ field }) => (
<Autocomplete
options={locationOptions}
loading={isLoadingLocations}
getOptionLabel={(option: LocationOption) => option.name}
value={
locationOptions.find((location) => String(location.id) === field.value) ?? null
}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
renderInput={(params) => (
<TextField
{...params}
label="Location"
required
error={Boolean(errors.locationId)}
helperText={errors.locationId?.message}
/>
)}
className="flex-1"
/>
)}
/>
<TextField
{...register("source")}
label="Source"
placeholder="e.g., Amazon APM, Manual"
fullWidth
/>
</Stack>
</Box>
);
}
type ClassificationFieldsProps = {
control: Control<WorkOrderFormSchemaValues>;
problems: DropdownOption[];
trades: DropdownOption[];
subTrades: DropdownOption[];
tradeValue: string;
};
export function ClassificationFields({
control,
problems,
trades,
subTrades,
tradeValue,
}: ClassificationFieldsProps) {
return (
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Controller
name="problem"
control={control}
render={({ field }) => (
<TextField {...field} value={field.value ?? ""} label="Problem" select fullWidth>
<MenuItem value="">Select Problem</MenuItem>
{problems.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="trade"
control={control}
render={({ field }) => (
<TextField {...field} value={field.value ?? ""} label="Trade" select fullWidth>
<MenuItem value="">Select Trade</MenuItem>
{trades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="subTrade"
control={control}
render={({ field }) => (
<TextField
{...field}
value={field.value ?? ""}
label="Sub-Trade"
select
disabled={!tradeValue}
fullWidth
>
<MenuItem value="">
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
</MenuItem>
{subTrades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
)}
/>
</Stack>
</Box>
);
}
type ScheduleFieldsProps = FormFieldHandlers & {
employees: EmployeeOption[];
};
export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) {
return (
<Box>
<SectionTitle>Schedule & Priority</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Controller
name="priority"
control={control}
render={({ field }) => (
<TextField
{...field}
value={field.value ?? ""}
label="Priority"
select
required
error={Boolean(errors.priority)}
helperText={errors.priority?.message}
fullWidth
>
<MenuItem value="">Select Priority</MenuItem>
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
<MenuItem key={priority} value={priority}>
{priority}
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<TextField
{...field}
value={field.value ?? "Open"}
label="Status"
select
required
fullWidth
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="assignedTo"
control={control}
render={({ field }) => (
<Autocomplete
freeSolo
options={employees.map((employee) => employee.name)}
value={field.value}
onChange={(_event, value) => field.onChange(value ?? "")}
onInputChange={(_event, value) => field.onChange(value)}
renderInput={(params) => (
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
)}
className="flex-1"
/>
)}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
<TextField
{...register("createdDate")}
label="Created"
type="date"
required
error={Boolean(errors.createdDate)}
helperText={errors.createdDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("dueDate")}
label="Due Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("scheduledDate")}
label="Scheduled"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("completedDate")}
label="Completed"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
</Box>
);
}
export function FinancialFields({
register,
}: {
register: UseFormRegister<WorkOrderFormSchemaValues>;
}) {
return (
<Box>
<SectionTitle>Financial</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
fullWidth
slotProps={{ htmlInput: { step: "0.01", min: 0 } }}
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField {...register("tt")} label="TT" placeholder="Trouble ticket number" fullWidth />
</Stack>
</Box>
);
}
export function DescriptionFields({
register,
}: {
register: UseFormRegister<WorkOrderFormSchemaValues>;
}) {
return (
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
);
}

View file

@ -1,62 +1,141 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import {
Alert,
Autocomplete,
Box,
Breadcrumbs,
Button,
CircularProgress,
Link,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
import {
workOrderFormSchema,
type WorkOrderFormSchemaValues,
} from "@/domain/work-orders/schemas/work-order-schema";
import {
createDefaultWorkOrderFormValues,
WORK_ORDER_PRIORITY_OPTIONS,
WORK_ORDER_STATUS_OPTIONS,
type LocationOption,
} from "@/domain/work-orders/types/work-order";
import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order";
import {
useWorkOrderDetail,
useWorkOrderEmployeesDropdown,
useWorkOrderLocations,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order";
import { toDateTimeLocalInputValue } from "@/lib/time-utils";
import {
useCreateWorkOrder,
useUpdateWorkOrder,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
ClassificationFields,
DescriptionFields,
FinancialFields,
ScheduleFields,
WorkOrderDetailsFields,
} from "@/app/(protected)/workorders/_components/work-order-fields";
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
function SectionTitle({ children }: { children: string }) {
return (
<Typography variant="subtitle2" className="mb-3 font-semibold uppercase tracking-wide">
{children}
</Typography>
);
type ScheduleBounds = {
kind: "valid" | "invalid" | "open";
startIso: string | undefined;
endIso: string | undefined;
error: string | null;
};
function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds {
const hasStart = startLocal.trim() !== "";
const hasEnd = endLocal.trim() !== "";
if (hasStart && hasEnd) {
const start = new Date(startLocal);
const end = new Date(endLocal);
if (
!Number.isNaN(start.getTime()) &&
!Number.isNaN(end.getTime()) &&
end.getTime() > start.getTime()
) {
return {
kind: "valid",
startIso: start.toISOString(),
endIso: end.toISOString(),
error: null,
};
}
return {
kind: "invalid",
startIso: undefined,
endIso: undefined,
error: "Schedule end must be later than schedule start.",
};
}
if (hasStart || hasEnd) {
return {
kind: "invalid",
startIso: undefined,
endIso: undefined,
error: "Provide both a schedule start and end, or leave both blank.",
};
}
return { kind: "open", startIso: undefined, endIso: undefined, error: null };
}
export default function WorkOrderFormPage() {
function useVendorSchedule(
scheduledStartLocal: string,
scheduledEndLocal: string,
locationValue: string,
tradeValue: string,
) {
const scheduleBounds = useMemo(
() => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal),
[scheduledStartLocal, scheduledEndLocal],
);
const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({
queryKey: [
"vendor-operations",
"availability",
scheduleBounds.startIso ?? null,
scheduleBounds.endIso ?? null,
locationValue,
tradeValue,
],
queryFn: () =>
vendorOperationsApi.availability({
start: scheduleBounds.startIso,
end: scheduleBounds.endIso,
locationId: locationValue || undefined,
trade: tradeValue || undefined,
}),
enabled: scheduleBounds.kind !== "invalid",
staleTime: 30_000,
});
const truthfulVendors =
scheduleBounds.kind === "valid"
? availableVendors
: availableVendors.map((vendor) => ({
...vendor,
availabilityStatus: "Unknown" as const,
conflicts: [],
}));
return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds };
}
function useWorkOrderFormState() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEdit = Boolean(id);
const assignVendorRequested = searchParams.get("assignVendor") === "1";
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const createMutation = useCreateWorkOrder();
const updateMutation = useUpdateWorkOrder(id ?? "");
const saveMutation = useSaveWorkOrder();
const vendorSectionRef = useRef<HTMLDivElement>(null);
const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null);
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
const [etaManualOverride, setEtaManualOverride] = useState(false);
const [scheduledStartLocal, setScheduledStartLocal] = useState("");
const [scheduledEndLocal, setScheduledEndLocal] = useState("");
const {
register,
@ -71,6 +150,14 @@ export default function WorkOrderFormPage() {
});
const tradeValue = useWatch({ control, name: "trade" });
const locationValue = useWatch({ control, name: "locationId" });
const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule(
scheduledStartLocal,
scheduledEndLocal,
locationValue,
tradeValue,
);
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
parent: tradeValue || undefined,
@ -81,6 +168,10 @@ export default function WorkOrderFormPage() {
useEffect(() => {
if (isEdit && detail) {
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled");
setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null);
setEstimatedArrivalAt(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt));
setEtaManualOverride(Boolean(dispatch?.etaManualOverride));
}
}, [detail, isEdit, reset]);
@ -90,23 +181,120 @@ export default function WorkOrderFormPage() {
}
}, [tradeValue, setValue]);
const locationOptions = useMemo(() => locations, [locations]);
const isSaving = createMutation.isPending || updateMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
useEffect(() => {
if (assignVendorRequested && !isLoading) {
const node = vendorSectionRef.current;
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "start" });
node.focus();
}
}
}, [assignVendorRequested, isLoading]);
const onSubmit = (values: WorkOrderFormSchemaValues) => {
if (isEdit && id) {
updateMutation.mutate(values);
if (scheduleBounds.kind === "invalid") {
return;
}
createMutation.mutate(values);
saveMutation.mutate({
workOrderId: isEdit ? id : undefined,
values,
vendorAssignment:
selectedVendorId == null
? undefined
: {
vendorId: selectedVendorId,
scheduledStart: scheduleBounds.startIso,
scheduledEnd: scheduleBounds.endIso,
estimatedArrivalAt: estimatedArrivalAt
? new Date(estimatedArrivalAt).toISOString()
: undefined,
etaManualOverride,
},
});
};
const handleCancel = () => {
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
};
return {
id,
isEdit,
detail,
isLoading,
detailError,
isSaving: saveMutation.isPending,
register,
control,
handleSubmit,
errors,
locationOptions: locations,
isLoadingLocations,
employees,
problems,
trades,
subTrades,
tradeValue,
vendorSectionRef,
availableVendors,
isLoadingVendors,
selectedVendorId,
setSelectedVendorId,
estimatedArrivalAt,
setEstimatedArrivalAt,
etaManualOverride,
setEtaManualOverride,
scheduledStartLocal,
setScheduledStartLocal,
scheduledEndLocal,
setScheduledEndLocal,
scheduleBoundsError: scheduleBounds.error,
isScheduleBoundsInvalid: scheduleBounds.kind === "invalid",
onSubmit,
handleCancel,
};
}
export default function WorkOrderFormPage() {
const {
isEdit,
id,
detail,
isLoading,
detailError,
isSaving,
register,
control,
handleSubmit,
errors,
locationOptions,
isLoadingLocations,
employees,
problems,
trades,
subTrades,
tradeValue,
vendorSectionRef,
availableVendors,
isLoadingVendors,
selectedVendorId,
setSelectedVendorId,
estimatedArrivalAt,
setEstimatedArrivalAt,
etaManualOverride,
setEtaManualOverride,
scheduledStartLocal,
setScheduledStartLocal,
scheduledEndLocal,
setScheduledEndLocal,
scheduleBoundsError,
isScheduleBoundsInvalid,
onSubmit,
handleCancel,
} = useWorkOrderFormState();
if (isLoading) {
return (
<Box className="flex justify-center p-12">
@ -118,9 +306,7 @@ export default function WorkOrderFormPage() {
if (detailError) {
return (
<Box className="p-4">
<Alert severity="error">
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
</Alert>
<Text variant="error">Unable to load this work order. Please try again.</Text>
</Box>
);
}
@ -131,232 +317,66 @@ export default function WorkOrderFormPage() {
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{isEdit ? "Edit" : "Create"}
</Typography>
<Text as="span">{isEdit ? "Edit" : "Create"}</Text>
</Breadcrumbs>
<Typography variant="h5" component="h1">
<Text variant="title" as="h1">
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
</Typography>
</Text>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("workOrderNumber")}
label="Customer WO"
placeholder="Customer work order number"
fullWidth
/>
<TextField
{...register("title")}
label="Title"
required
error={Boolean(errors.title)}
helperText={errors.title?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
<Controller
name="locationId"
control={control}
render={({ field }) => (
<Autocomplete
options={locationOptions}
loading={isLoadingLocations}
getOptionLabel={(option: LocationOption) => option.name}
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
renderInput={(params) => (
<TextField
{...params}
label="Location"
required
error={Boolean(errors.locationId)}
helperText={errors.locationId?.message}
/>
)}
className="flex-1"
/>
)}
/>
<TextField
{...register("source")}
label="Source"
placeholder="e.g., Amazon APM, Manual"
fullWidth
/>
</Stack>
</Box>
<WorkOrderDetailsFields
register={register}
control={control}
errors={errors}
locationOptions={locationOptions}
isLoadingLocations={isLoadingLocations}
/>
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("problem")} label="Problem" select fullWidth>
<MenuItem value="">Select Problem</MenuItem>
{problems.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField {...register("trade")} label="Trade" select fullWidth>
<MenuItem value="">Select Trade</MenuItem>
{trades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField
{...register("subTrade")}
label="Sub-Trade"
select
disabled={!tradeValue}
fullWidth
>
<MenuItem value="">
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
</MenuItem>
{subTrades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
</Stack>
</Box>
<VendorAndTimeSection
containerRef={vendorSectionRef}
availableVendors={availableVendors}
isLoadingVendors={isLoadingVendors}
selectedVendorId={selectedVendorId}
onSelectVendor={setSelectedVendorId}
estimatedArrivalAt={estimatedArrivalAt}
onEtaChange={setEstimatedArrivalAt}
etaManualOverride={etaManualOverride}
onOverrideChange={setEtaManualOverride}
scheduledStartLocal={scheduledStartLocal}
onScheduledStartChange={setScheduledStartLocal}
scheduledEndLocal={scheduledEndLocal}
onScheduledEndChange={setScheduledEndLocal}
scheduleBoundsError={scheduleBoundsError}
/>
<Box>
<SectionTitle>Schedule & Priority</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("priority")}
label="Priority"
select
required
error={Boolean(errors.priority)}
helperText={errors.priority?.message}
fullWidth
>
<MenuItem value="">Select Priority</MenuItem>
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
<MenuItem key={priority} value={priority}>
{priority}
</MenuItem>
))}
</TextField>
<TextField {...register("status")} label="Status" select required fullWidth>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
<Controller
name="assignedTo"
control={control}
render={({ field }) => (
<Autocomplete
freeSolo
options={employees.map((employee) => employee.name)}
value={field.value}
onChange={(_event, value) => field.onChange(value ?? "")}
onInputChange={(_event, value) => field.onChange(value)}
renderInput={(params) => (
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
)}
className="flex-1"
/>
)}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
<TextField
{...register("createdDate")}
label="Created"
type="date"
required
error={Boolean(errors.createdDate)}
helperText={errors.createdDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("dueDate")}
label="Due Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("scheduledDate")}
label="Scheduled"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("completedDate")}
label="Completed"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
</Box>
<ClassificationFields
control={control}
problems={problems}
trades={trades}
subTrades={subTrades}
tradeValue={tradeValue}
/>
<Box>
<SectionTitle>Financial</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
{...register("tt")}
label="TT"
placeholder="Trouble ticket number"
fullWidth
/>
</Stack>
</Box>
<ScheduleFields
register={register}
control={control}
errors={errors}
employees={employees}
/>
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
<FinancialFields register={register} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<DescriptionFields register={register} />
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
<Button
type="submit"
variant="contained"
disabled={isSaving || isScheduleBoundsInvalid}
>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>

View file

@ -91,7 +91,7 @@ export default function WorkOrdersListPage() {
scopeTotalCount={scopeTotalCount}
/>
{filters.advApplied && (
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
@ -131,7 +131,7 @@ export default function WorkOrdersListPage() {
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
{activeMediaRow && (
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}

View file

@ -1,6 +1,7 @@
import { NavLink, Outlet, useLocation, useParams } from "react-router";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
import { Text } from "@/components/ui/text";
import "@/app/v/_components/vendor-portal.css";
function VendorPortalHeader() {
@ -9,7 +10,7 @@ function VendorPortalHeader() {
return (
<div className="vp-header">
<div className="vp-brand">Sea Haven Industries — Vendor Portal</div>
{status === "ready" && vendor?.companyName && (
{status === "ready" && vendor != null && Boolean(vendor.companyName) && (
<div className="vp-vendor-name">{vendor.companyName}</div>
)}
</div>
@ -55,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") {
return (
<div className="vp-container">
<div className="vp-error">
<h2>Access Denied</h2>
<p>
<Text as="section" variant="body" className="vp-error">
<Text variant="title">Access Denied</Text>
<Text variant="error">
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
</p>
</div>
</Text>
</Text>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() {
return (
<>
<div className="vp-card">
<h2>Your Work</h2>
<Text variant="title">Your Work</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Active dispatches and work orders assigned to your company.
</div>
@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading dispatches…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && filtered.length === 0 && (
<div className="vp-card vp-empty">No dispatches in this view.</div>

View file

@ -1,404 +1,21 @@
import { useCallback, useState } from "react";
import { Link, useParams } from "react-router";
import { useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { Text } from "@/components/ui/text";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type {
VendorPortalChecklistItem,
VendorPortalComment,
VendorPortalDispatchDetail,
VendorPortalSignoff,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { queryKeys } from "@/infra/query-key/query-key";
function ChecklistSection({
token,
dispatchId,
items,
locked,
onItemUpdated,
}: {
token: string;
dispatchId: number;
items: VendorPortalChecklistItem[];
locked: boolean;
onItemUpdated: (item: VendorPortalChecklistItem) => void;
}) {
const toggle = async (item: VendorPortalChecklistItem) => {
if (locked) return;
try {
const updated = await vendorPortalApi.updateChecklistItem(
token,
dispatchId,
item.id,
!item.isCompleted,
);
onItemUpdated(updated);
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to update checklist");
}
};
if (!items.length) {
return <div className="vp-muted">No checklist items for this dispatch.</div>;
}
return (
<div>
{items.map((item) => (
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
<input
type="checkbox"
id={`cl-${item.id}`}
checked={item.isCompleted}
onChange={() => void toggle(item)}
disabled={locked}
/>
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText}
</label>
{item.isCompleted && item.completedAt && (
<span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)}
</span>
)}
</div>
))}
</div>
);
}
function CommentsSection({
token,
dispatchId,
comments,
onAdded,
}: {
token: string;
dispatchId: number;
comments: VendorPortalComment[];
onAdded: (comment: VendorPortalComment) => void;
}) {
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!text.trim()) return;
setSubmitting(true);
try {
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
onAdded(added);
setText("");
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to post comment");
} finally {
setSubmitting(false);
}
};
return (
<>
{comments.length === 0 && <div className="vp-muted">No comments yet.</div>}
{comments.map((comment) => (
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
<div className="vp-comment-meta">
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
</div>
<div>{comment.commenttext}</div>
</div>
))}
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<textarea
className="vp-textarea"
placeholder="Add a comment for the dispatcher…"
value={text}
onChange={(event) => setText(event.target.value)}
/>
<div className="vp-actions">
<button
type="submit"
className="vp-btn vp-btn-primary"
disabled={submitting || !text.trim()}
>
{submitting ? "Posting…" : "Post Comment"}
</button>
</div>
</form>
</>
);
}
function UpliftRequestsSection({
token,
dispatchId,
currentNte,
upliftRequests,
locked,
onChanged,
}: {
token: string;
dispatchId: number;
currentNte?: number;
upliftRequests: VendorPortalUpliftRequest[];
locked: boolean;
onChanged: () => Promise<void>;
}) {
const [requested, setRequested] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const pending = upliftRequests.find((request) => request.status === "Pending");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
if (!Number.isFinite(amount) || amount <= 0) {
alert("Enter a valid requested NTE amount.");
return;
}
if (currentNte != null && amount <= Number(currentNte)) {
alert(
`Requested NTE must be greater than the current NTE ($${Number(currentNte).toFixed(2)}).`,
);
return;
}
if (!reason.trim()) {
alert("Please include a reason for the uplift request.");
return;
}
setSubmitting(true);
try {
await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: amount,
reason: reason.trim(),
});
setRequested("");
setReason("");
await onChanged();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to request uplift");
} finally {
setSubmitting(false);
}
};
const cancel = async (requestId: number) => {
if (!window.confirm("Cancel this uplift request?")) return;
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to cancel uplift");
}
};
return (
<>
{upliftRequests.length === 0 && !pending && (
<div className="vp-muted" style={{ marginBottom: 12 }}>
No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div>
)}
{upliftRequests.map((request) => (
<div key={request.id} className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
<strong>Status</strong>
<span>
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
{request.status}
</span>
</span>
</div>
{request.vendorReason && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{request.status === "Pending" && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => void cancel(request.id)}
>
Cancel Request
</button>
</div>
)}
</div>
))}
{!locked && !pending && (
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<div className="vp-row">
<strong>Current NTE</strong>
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
</div>
<div className="vp-row">
<strong>New total NTE</strong>
<input
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => setRequested(event.target.value)}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)}
</>
);
}
function SignoffSection({
token,
dispatchId,
existing,
signoffType,
nameLabel,
onAdded,
}: {
token: string;
dispatchId: number;
existing: VendorPortalSignoff[];
signoffType: string;
nameLabel: string;
onAdded: () => void;
}) {
const [name, setName] = useState("");
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
const [typedSig, setTypedSig] = useState("");
const [drawnSig, setDrawnSig] = useState("");
const [submitting, setSubmitting] = useState(false);
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
if (existingSignoff) {
return (
<div>
<div className="vp-row">
<strong>Signed by</strong>
<span>{existingSignoff.name}</span>
</div>
<div className="vp-row">
<strong>Method</strong>
<span>{existingSignoff.signatureMethod}</span>
</div>
<div className="vp-row">
<strong>Signed at</strong>
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
</div>
</div>
);
}
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;
if (!name.trim() || !signature) {
alert("Name and signature are required");
return;
}
setSubmitting(true);
try {
await vendorPortalApi.addSignoff(token, dispatchId, {
name: name.trim(),
signature,
signatureMethod: method,
signoffType,
});
onAdded();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to submit signoff");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={(event) => void submit(event)}>
<div className="vp-row">
<strong>{nameLabel}</strong>
<input
className="vp-input"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="vp-row">
<strong>Method</strong>
<div>
<label style={{ marginRight: 12 }}>
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
Drawn
</label>
<label>
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
Typed
</label>
</div>
</div>
{method === "drawn" ? (
<div style={{ marginTop: 8 }}>
<SignaturePad onChange={setDrawnSig} />
</div>
) : (
<div className="vp-row">
<strong>Signature</strong>
<input
className="vp-input"
value={typedSig}
onChange={(event) => setTypedSig(event.target.value)}
/>
</div>
)}
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
{submitting ? "Signing…" : "Submit Signoff"}
</button>
</div>
</form>
);
}
export default function VendorPortalDispatchPage() {
const { token } = useVendorPortal();
const { id = "" } = useParams();
const dispatchId = Number(id);
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
@ -413,29 +30,18 @@ export default function VendorPortalDispatchPage() {
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
if (confirmMsg && !window.confirm(confirmMsg)) return;
setActionError("");
setBusy(true);
try {
await action();
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Action failed");
} catch {
setActionError("Unable to update this dispatch. Please try again.");
} finally {
setBusy(false);
}
};
if (isLoading && !data) {
return <div className="vp-card vp-muted">Loading dispatch…</div>;
}
if (error && !data) {
return <div className="vp-error">{error.message}</div>;
}
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
const updateLocal = (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => {
@ -448,195 +54,59 @@ export default function VendorPortalDispatchPage() {
);
};
const requestCancel = () => {
const reason = window.prompt("Reason for cancel request?");
if (reason == null) return;
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
};
if (isLoading && !data) {
return (
<Text variant="feedback" className="vp-card vp-muted">
Loading dispatch…
</Text>
);
}
if (error && !data) {
return <Text variant="error">Unable to load this dispatch. Please try again.</Text>;
}
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
return (
<>
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
← Back to dispatches
</Link>
<DispatchSummaryCard
token={token}
data={data}
locked={locked}
busy={busy}
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
onStart={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
}
onComplete={() =>
void runAction(
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
"Mark this dispatch as completed?",
)
}
onRequestCancel={requestCancel}
/>
<Text variant="error" when={Boolean(actionError)}>
{actionError}
</Text>
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2>{data.dispatchNumber}</h2>
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
</div>
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<div style={{ marginTop: 12 }}>
{data.workOrder?.internalWONumber && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{data.poNumber && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
</div>
)}
{data.nteAmount != null && (
<div className="vp-row">
<strong>NTE</strong>
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
{data.scheduledDate && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
</div>
{data.location && (
<>
<h3 style={{ marginTop: 16 }}>Location</h3>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ")}
</div>
</>
)}
{(data.description || data.workOrder?.description) && (
<>
<h3 style={{ marginTop: 16 }}>Description</h3>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
<div className="vp-actions">
{data.status === "Sent" && (
<button
type="button"
className="vp-btn vp-btn-success"
disabled={busy}
onClick={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
>
Accept Dispatch
</button>
)}
{data.status === "Acknowledged" && (
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={busy}
onClick={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
}
>
Start Work
</button>
)}
{data.status === "In Progress" && (
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={busy}
onClick={() =>
void runAction(
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
"Mark this dispatch as completed?",
)
}
>
Mark Completed
</button>
)}
{!locked && data.status !== "Sent" && (
<button
type="button"
className="vp-btn vp-btn-warn"
disabled={busy}
onClick={() => {
const reason = window.prompt("Reason for cancel request?");
if (reason == null) return;
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
}}
>
Request Cancel
</button>
)}
</div>
</div>
<div className="vp-card">
<h3>Checklist</h3>
<ChecklistSection
token={token}
dispatchId={dispatchId}
items={data.checklist ?? []}
locked={locked}
onItemUpdated={(updated) =>
updateLocal((current) => ({
...current,
checklist: (current.checklist ?? []).map((item) =>
item.id === updated.id ? { ...item, ...updated } : item,
),
}))
}
/>
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<h3>Comments</h3>
<CommentsSection
token={token}
dispatchId={dispatchId}
comments={data.comments ?? []}
onAdded={(comment) =>
updateLocal((current) => ({
...current,
comments: [...(current.comments ?? []), comment],
}))
}
/>
</div>
<DispatchDetailSections
token={token}
dispatchId={dispatchId}
data={data}
locked={locked}
refresh={refresh}
updateLocal={updateLocal}
/>
</>
);
}

View file

@ -0,0 +1,70 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
type ChecklistSectionProps = {
token: string;
dispatchId: number;
items: VendorPortalChecklistItem[];
locked: boolean;
onItemUpdated: (item: VendorPortalChecklistItem) => void;
};
export function ChecklistSection({
token,
dispatchId,
items,
locked,
onItemUpdated,
}: ChecklistSectionProps) {
const [errorMessage, setErrorMessage] = useState("");
const toggle = async (item: VendorPortalChecklistItem) => {
if (locked) return;
setErrorMessage("");
try {
const updated = await vendorPortalApi.updateChecklistItem(
token,
dispatchId,
item.id,
!item.isCompleted,
);
onItemUpdated(updated);
} catch {
setErrorMessage("Unable to update the checklist. Please try again.");
}
};
if (!items.length) {
return <Text tone="muted">No checklist items for this dispatch.</Text>;
}
return (
<div>
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{items.map((item) => (
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
<input
type="checkbox"
id={`cl-${item.id}`}
checked={item.isCompleted}
onChange={() => void toggle(item)}
disabled={locked}
/>
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText}
</label>
{item.isCompleted && Boolean(item.completedAt) && (
<span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)}
</span>
)}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
type CommentsSectionProps = {
token: string;
dispatchId: number;
comments: VendorPortalComment[];
onAdded: (comment: VendorPortalComment) => void;
};
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!text.trim()) return;
setErrorMessage("");
setSubmitting(true);
try {
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
onAdded(added);
setText("");
} catch {
setErrorMessage("Unable to post your comment. Please try again.");
} finally {
setSubmitting(false);
}
};
return (
<>
{comments.length === 0 && <Text tone="muted">No comments yet.</Text>}
{comments.map((comment) => (
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
<div className="vp-comment-meta">
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
</div>
<div>{comment.commenttext}</div>
</div>
))}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<textarea
className="vp-textarea"
placeholder="Add a comment for the dispatcher…"
value={text}
onChange={(event) => {
setText(event.target.value);
setErrorMessage("");
}}
/>
<div className="vp-actions">
<button
type="submit"
className="vp-btn vp-btn-primary"
disabled={submitting || !text.trim()}
>
{submitting ? "Posting…" : "Post Comment"}
</button>
</div>
</form>
</>
);
}

View file

@ -0,0 +1,111 @@
import { Text } from "@/components/ui/text";
import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist-section";
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
type DispatchDetailSectionsProps = {
token: string;
dispatchId: number;
data: VendorPortalDispatchDetail;
locked: boolean;
refresh: () => Promise<void>;
updateLocal: (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => void;
};
export function DispatchDetailSections({
token,
dispatchId,
data,
locked,
refresh,
updateLocal,
}: DispatchDetailSectionsProps) {
return (
<>
<div className="vp-card">
<Text variant="heading">Checklist</Text>
<ChecklistSection
token={token}
dispatchId={dispatchId}
items={data.checklist ?? []}
locked={locked}
onItemUpdated={(updated) =>
updateLocal((current) => ({
...current,
checklist: (current.checklist ?? []).map((item) =>
item.id === updated.id ? { ...item, ...updated } : item,
),
}))
}
/>
</div>
<div className="vp-card">
<Text variant="heading">Completion documents</Text>
<VendorPortalDocuments
token={token}
dispatchId={dispatchId}
documents={data.documents ?? []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<Text variant="heading">NTE Uplift Requests</Text>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<Text variant="heading">Customer Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<Text variant="heading">Vendor Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<Text variant="heading">Comments</Text>
<CommentsSection
token={token}
dispatchId={dispatchId}
comments={data.comments ?? []}
onAdded={(comment) =>
updateLocal((current) => ({
...current,
comments: [...(current.comments ?? []), comment],
}))
}
/>
</div>
</>
);
}

View file

@ -0,0 +1,54 @@
type DispatchStatusActionsProps = {
status: string;
locked: boolean;
busy: boolean;
onAccept: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
};
export function DispatchStatusActions({
status,
locked,
busy,
onAccept,
onStart,
onComplete,
onRequestCancel,
}: DispatchStatusActionsProps) {
return (
<div className="vp-actions">
{status === "Sent" && (
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
Accept Dispatch
</button>
)}
{status === "Acknowledged" && (
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
Start Work
</button>
)}
{status === "In Progress" && (
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={busy}
onClick={onComplete}
>
Mark Completed
</button>
)}
{!locked && status !== "Sent" && (
<button
type="button"
className="vp-btn vp-btn-warn"
disabled={busy}
onClick={onRequestCancel}
>
Request Cancel
</button>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
type DispatchSummaryCardProps = {
token: string;
data: VendorPortalDispatchDetail;
locked: boolean;
busy: boolean;
onAccept: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
};
export function DispatchSummaryCard({
token,
data,
locked,
busy,
onAccept,
onStart,
onComplete,
onRequestCancel,
}: DispatchSummaryCardProps) {
return (
<>
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
← Back to dispatches
</Link>
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<Text variant="title">{data.dispatchNumber}</Text>
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
</div>
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<div style={{ marginTop: 12 }}>
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{Boolean(data.poNumber) && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
</div>
)}
{data.nteAmount != null && (
<div className="vp-row">
<strong>NTE</strong>
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
</div>
{data.location != null && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ")}
</div>
</>
)}
{Boolean(data.description || data.workOrder?.description) && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
<DispatchStatusActions
status={data.status}
locked={locked}
busy={busy}
onAccept={onAccept}
onStart={onStart}
onComplete={onComplete}
onRequestCancel={onRequestCancel}
/>
</div>
</>
);
}

View file

@ -0,0 +1,135 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
type SignoffSectionProps = {
token: string;
dispatchId: number;
existing: VendorPortalSignoff[];
signoffType: string;
nameLabel: string;
onAdded: () => void;
};
export function SignoffSection({
token,
dispatchId,
existing,
signoffType,
nameLabel,
onAdded,
}: SignoffSectionProps) {
const [name, setName] = useState("");
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
const [typedSig, setTypedSig] = useState("");
const [drawnSig, setDrawnSig] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
if (existingSignoff) {
return (
<div>
<div className="vp-row">
<strong>Signed by</strong>
<span>{existingSignoff.name}</span>
</div>
<div className="vp-row">
<strong>Method</strong>
<span>{existingSignoff.signatureMethod}</span>
</div>
<div className="vp-row">
<strong>Signed at</strong>
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
</div>
</div>
);
}
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;
if (!name.trim() || !signature) {
setErrorMessage("Name and signature are required.");
return;
}
setErrorMessage("");
setSubmitting(true);
try {
await vendorPortalApi.addSignoff(token, dispatchId, {
name: name.trim(),
signature,
signatureMethod: method,
signoffType,
});
onAdded();
} catch {
setErrorMessage("Unable to submit the signoff. Please try again.");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={(event) => void submit(event)}>
<div className="vp-row">
<strong>{nameLabel}</strong>
<input
className="vp-input"
value={name}
onChange={(event) => {
setName(event.target.value);
setErrorMessage("");
}}
/>
</div>
<div className="vp-row">
<strong>Method</strong>
<div>
<label style={{ marginRight: 12 }}>
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
Drawn
</label>
<label>
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
Typed
</label>
</div>
</div>
{method === "drawn" ? (
<div style={{ marginTop: 8 }}>
<SignaturePad
onChange={(signature) => {
setDrawnSig(signature);
setErrorMessage("");
}}
/>
</div>
) : (
<div className="vp-row">
<strong>Signature</strong>
<input
className="vp-input"
value={typedSig}
onChange={(event) => {
setTypedSig(event.target.value);
setErrorMessage("");
}}
/>
</div>
)}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
{submitting ? "Signing…" : "Submit Signoff"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,169 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
type UpliftRequestsSectionProps = {
token: string;
dispatchId: number;
currentNte?: number;
upliftRequests: VendorPortalUpliftRequest[];
locked: boolean;
onChanged: () => Promise<void>;
};
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) {
const amount = Number.parseFloat(requested);
if (!Number.isFinite(amount) || amount <= 0) {
return "Enter a valid requested NTE amount.";
}
if (currentNte != null && amount <= currentNte) {
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`;
}
if (!reason.trim()) {
return "Please include a reason for the uplift request.";
}
return "";
}
export function UpliftRequestsSection({
token,
dispatchId,
currentNte,
upliftRequests,
locked,
onChanged,
}: UpliftRequestsSectionProps) {
const [requested, setRequested] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const pending = upliftRequests.find((request) => request.status === "Pending");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
const validationError = validateUpliftRequest(requested, currentNte, reason);
setErrorMessage(validationError);
if (validationError) {
return;
}
setSubmitting(true);
try {
await vendorPortalApi.requestUplift(token, dispatchId, {
requestedNTE: amount,
reason: reason.trim(),
});
setRequested("");
setReason("");
await onChanged();
} catch {
setErrorMessage("Unable to request the NTE uplift. Please try again.");
} finally {
setSubmitting(false);
}
};
const cancel = async (requestId: number) => {
if (!window.confirm("Cancel this uplift request?")) return;
setErrorMessage("");
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch {
setErrorMessage("Unable to cancel the NTE uplift request. Please try again.");
}
};
return (
<>
{upliftRequests.length === 0 && !pending && (
<div className="vp-muted" style={{ marginBottom: 12 }}>
No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div>
)}
{upliftRequests.map((request) => (
<div key={request.id} className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
<strong>Status</strong>
<span>
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
{request.status}
</span>
</span>
</div>
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{request.status === "Pending" && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => void cancel(request.id)}
>
Cancel Request
</button>
</div>
)}
</div>
))}
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
{!locked && !pending && (
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<div className="vp-row">
<strong>Current NTE</strong>
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
</div>
<div className="vp-row">
<strong>New total NTE</strong>
<input
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => {
setRequested(event.target.value);
setErrorMessage("");
}}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(event) => {
setReason(event.target.value);
setErrorMessage("");
}}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)}
</>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -81,7 +82,7 @@ export default function VendorPortalPosPage() {
return (
<>
<div className="vp-card">
<h2>Your Purchase Orders</h2>
<Text variant="title">Your Purchase Orders</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Every PO issued to your company, with current status and dollar value.
</div>
@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading POs…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && visible.length === 0 && (
<div className="vp-card vp-empty">No POs match this view.</div>

View file

@ -0,0 +1,162 @@
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
type VendorPortalDocumentsProps = {
token: string;
dispatchId: number;
documents: VendorPortalDocument[];
locked: boolean;
onChanged: () => Promise<void>;
};
type UploadRequest = {
file: File;
replacementId?: number;
};
export function VendorPortalDocuments({
token,
dispatchId,
documents,
locked,
onChanged,
}: VendorPortalDocumentsProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
vendorPortalApi.uploadDocument({
token,
dispatchId,
file,
replacesDocumentId: requestedReplacementId,
onProgress: setProgress,
}),
onSuccess: onChanged,
onSettled: () => {
setProgress(undefined);
setReplacementId(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
},
});
const chooseFile = (documentId?: number) => {
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
inputRef.current?.click();
};
const uploadSelectedFile = (file?: File) => {
if (!file) {
return;
}
const validationMessage = validateVendorDocument(file);
if (validationMessage) {
setValidationError(validationMessage);
return;
}
setValidationError(undefined);
setProgress(0);
uploadMutation.mutate({ file, replacementId });
};
const uploadError =
validationError ??
(uploadMutation.error ? "Unable to upload the document. Please try again." : undefined);
return (
<>
<input
ref={inputRef}
type="file"
aria-label="Choose completion document"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
hidden
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
/>
{documents.length === 0 && (
<VendorPortalFeedback>No completion documents uploaded.</VendorPortalFeedback>
)}
{documents.map((document) => (
<article key={document.id} className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{Boolean(document.rejectionReason) && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploadMutation.isPending}
onClick={() => chooseFile(document.id)}
>
Replace
</button>
)}
</div>
</article>
))}
{progress !== undefined && (
<div role="status" aria-live="polite">
<progress max={100} value={progress} style={{ width: "100%" }} />
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
</div>
)}
{Boolean(uploadError) && (
<VendorPortalFeedback tone="error">{uploadError}</VendorPortalFeedback>
)}
{!locked && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={uploadMutation.isPending}
onClick={() => chooseFile()}
>
Upload document
</button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
type VendorPortalFeedbackProps = {
children: ReactNode;
tone?: "error" | "muted";
};
export function VendorPortalFeedback({ children, tone = "muted" }: VendorPortalFeedbackProps) {
return (
<Text
variant={tone === "error" ? "error" : "feedback"}
tone={tone}
className={tone === "error" ? "vp-error" : "vp-muted"}
>
{children}
</Text>
);
}

View file

@ -22,7 +22,7 @@ export const EventCalendar = forwardRef<FullCalendar, EventCalendarProps>(functi
) {
return (
<Box className="relative rounded-lg border border-border bg-card p-3">
{loading && (
{Boolean(loading) && (
<Box className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/70">
<CircularProgress size={32} />
</Box>

View file

@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
fullWidth
size="small"
/>
{typedName && (
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"

View file

@ -32,7 +32,7 @@ export function CollapsibleSection({
aria-expanded={open}
>
<div className="flex items-center gap-2">
{Icon && <Icon size={16} className="text-primary" />}
{Icon != null && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span>
</div>
<div className="flex items-center gap-2">

View file

@ -1,13 +1,13 @@
import type { ReactNode } from "react";
import type { ReactElement } from "react";
import { Box, Typography } from "@mui/material";
import { cn } from "@/lib/utils";
type EmptyStateProps = {
icon?: ReactNode;
icon?: ReactElement;
title: string;
description?: string;
action?: ReactNode;
action?: ReactElement;
className?: string;
};
@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt
className,
)}
>
{icon && <Box className="text-muted-foreground">{icon}</Box>}
{Boolean(icon) && <Box className="text-muted-foreground">{icon}</Box>}
<Typography variant="subtitle1" className="text-foreground">
{title}
</Typography>
{description && (
{Boolean(description) && (
<Typography
variant="body2"
className="max-w-sm"
@ -34,7 +34,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
{description}
</Typography>
)}
{action && <Box className="mt-2">{action}</Box>}
{Boolean(action) && <Box className="mt-2">{action}</Box>}
</Box>
);
}

View file

@ -34,7 +34,7 @@ export function FilterPopover({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Filters</span>
{activeCount > 0 && onClear && (
{activeCount > 0 && Boolean(onClear) && (
<button
type="button"
onClick={onClear}

View file

@ -64,7 +64,7 @@ export function FormField({
{label}
</FormLabel>
{children}
{helperText && <FormHelperText>{helperText}</FormHelperText>}
{Boolean(helperText) && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
}

View file

@ -1,13 +1,14 @@
import type { ReactNode } from "react";
import { Stack, Typography } from "@mui/material";
import type { ReactElement } from "react";
import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
type PageHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
actions?: ReactNode;
actions?: ReactElement;
className?: string;
/** White surface bar (list page headers). */
surface?: boolean;
@ -35,26 +36,27 @@ export function PageHeader({
}}
>
<Stack spacing={0}>
{eyebrow && (
<p className="m-0 font-sans text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase">
{eyebrow}
</p>
)}
<h1
<Text
variant="label"
when={Boolean(eyebrow)}
className="m-0 text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase"
>
{eyebrow}
</Text>
<Text
variant="display"
className={cn(
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
>
{title}
</h1>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Text>
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
{subtitle}
</Text>
</Stack>
{actions && (
{Boolean(actions) && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
{actions}
</Stack>

104
src/components/ui/text.tsx Normal file
View file

@ -0,0 +1,104 @@
import type { ElementType, ReactNode } from "react";
import {
Typography as MuiTypography,
type TypographyProps as MuiTypographyProps,
} from "@mui/material";
type TextVariant =
| "display"
| "title"
| "heading"
| "body"
| "description"
| "label"
| "feedback"
| "error"
| "caption"
| "code";
type TextTone = "default" | "muted" | "error" | "success" | "warning";
type TextFamily = "display" | "body" | "mono";
export type TextProps = Omit<
MuiTypographyProps,
"children" | "color" | "variant" | "component" | "role" | "aria-live" | "aria-atomic"
> & {
children: ReactNode;
as?: ElementType;
family?: TextFamily;
tone?: TextTone;
variant?: TextVariant;
when?: boolean;
};
const variantConfig: Record<
TextVariant,
{ element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily }
> = {
display: { element: "h1", muiVariant: "h3", family: "display" },
title: { element: "h2", muiVariant: "h5", family: "display" },
heading: { element: "h3", muiVariant: "h6", family: "display" },
body: { element: "p", muiVariant: "body1", family: "body" },
description: { element: "p", muiVariant: "body2", family: "body" },
label: { element: "span", muiVariant: "subtitle2", family: "body" },
feedback: { element: "p", muiVariant: "body2", family: "body" },
error: { element: "p", muiVariant: "body2", family: "body" },
caption: { element: "span", muiVariant: "caption", family: "body" },
code: { element: "code", muiVariant: "body2", family: "mono" },
};
const toneColor: Record<TextTone, MuiTypographyProps["color"]> = {
default: "text.primary",
muted: "text.secondary",
error: "error",
success: "success.main",
warning: "warning.main",
};
const familyValue: Record<TextFamily, string> = {
display: "var(--font-display)",
body: "var(--font-sans)",
mono: "var(--font-mono)",
};
export function Text({
as,
children,
family,
tone,
variant = "body",
when = true,
sx,
...props
}: TextProps) {
const persistRegion = variant === "feedback";
if (!when && !persistRegion) {
return null;
}
const config = variantConfig[variant];
const resolvedTone = tone ?? (variant === "error" ? "error" : "default");
let liveProps = {};
if (variant === "error") {
liveProps = { role: "alert", "aria-live": "assertive" as const, "aria-atomic": true as const };
} else if (variant === "feedback") {
liveProps = { role: "status", "aria-live": "polite" as const, "aria-atomic": true as const };
}
return (
<MuiTypography
{...props}
component={as ?? config.element}
variant={config.muiVariant}
color={toneColor[resolvedTone]}
sx={[
{ fontFamily: familyValue[family ?? config.family] },
...(Array.isArray(sx) ? sx : [sx]),
]}
{...liveProps}
>
{when && children}
</MuiTypography>
);
}

View file

@ -6,6 +6,9 @@ import {
ClipboardList,
FileText,
LayoutDashboard,
Bell,
BarChart3,
Star,
} from "lucide-react";
export type NavItemDef = {
@ -34,6 +37,7 @@ export const navGroups: NavGroup[] = [
items: [
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
{ label: "Notifications", icon: Bell, path: "/notifications" },
// { label: "Reports", icon: BarChart2, path: "/reports" },
],
},
@ -42,6 +46,8 @@ export const navGroups: NavGroup[] = [
items: [
{ label: "Vendors", icon: Building2, path: "/vendors" },
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
],
},
{

View file

@ -0,0 +1,81 @@
import { api, apiGet, apiPut } from "@/api/api";
import type {
SiteVendorPreference,
VendorAssignmentResult,
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
function searchParams(values: Record<string, string | number | undefined>) {
return Object.fromEntries(
Object.entries(values).filter(
(entry): entry is [string, string | number] => entry[1] !== undefined,
),
);
}
type InsightParams = {
from?: string;
to?: string;
locationId?: string | number;
trade?: string;
vendorId?: string | number;
};
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: {
start?: string;
end?: string;
locationId?: string | number;
trade?: string;
}): Promise<VendorAvailability[]> => {
const response = await apiGet<{ data?: VendorAvailability[]; Data?: VendorAvailability[] }>(
`${base}/availability`,
{ searchParams: searchParams(params) },
);
return response.data ?? response.Data ?? [];
},
insights: async (params: InsightParams): Promise<VendorInsight[]> => {
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
`${base}/insights`,
{ searchParams: searchParams(params) },
);
return response.data ?? response.Data ?? [];
},
downloadInsights: (format: "csv" | "pdf", params: InsightParams) =>
saveDownload(`insights.${format}`, `vendor-insights.${format}`, params),
sitePreferences: (locationId: string | number) =>
apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`),
replaceSitePreferences: (
locationId: string | number,
preferences: Array<{ vendorId: number; trade?: string; sortOrder: number }>,
) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences),
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
apiPut<VendorAssignmentResult>(`${base}/work-orders/${workOrderId}/assignment`, payload),
};

View file

@ -0,0 +1,79 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability {
id: number;
companyName: string;
contactName?: string;
tradeSpecialties?: string;
availabilityStatus: "Available" | "Unavailable" | "Unknown";
availabilityUpdatedAt?: string;
isPreferred: boolean;
preferenceRank?: number;
conflicts: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorInsight {
vendorId: number;
vendor: string;
completedJobs: number;
cancellationRefusalRate: number;
onTimeArrivalRate: number;
onTimeCompletionRate: number;
averageCycleHours?: number;
}
export interface SiteVendorPreference {
id: number;
locationId: number;
vendorId: number;
vendorName: string;
trade?: string;
sortOrder: number;
isStale: boolean;
}
export interface VendorAssignmentPayload {
vendorId: number;
scheduledStart?: string;
scheduledEnd?: string;
estimatedArrivalAt?: string;
etaManualOverride: boolean;
}
export interface VendorAssignmentResult {
availabilityStatus: "Available" | "Unavailable" | "Unknown";
conflicts: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}

View file

@ -5,6 +5,7 @@ import type {
VendorPortalComment,
VendorPortalDispatchDetail,
VendorPortalDispatchSummary,
VendorPortalDocument,
VendorPortalSession,
VendorPortalSignoff,
} from "@/domain/vendor-portal/types/vendor-portal";
@ -14,6 +15,66 @@ function resolveVendorPortalUrl(path: string): string {
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
}
type UploadVendorDocumentOptions = {
token: string;
dispatchId: string | number;
file: File;
replacesDocumentId?: number;
onProgress: (percent: number) => void;
};
function uploadVendorDocument({
token,
dispatchId,
file,
replacesDocumentId,
onProgress,
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
request.setRequestHeader("X-Vendor-Token", token);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
request.addEventListener("load", () => {
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
if (request.status < 200 || request.status >= 300) {
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
return;
}
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
});
request.addEventListener("error", () => reject(new Error("Upload failed")));
const body = new FormData();
body.append("file", file);
if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId));
}
request.send(body);
});
}
async function downloadVendorDocument(
token: string,
dispatchId: string | number,
document: VendorPortalDocument,
): Promise<void> {
const response = await fetch(
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
{ headers: { "X-Vendor-Token": token } },
);
if (!response.ok)
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = document.originalFileName;
anchor.click();
URL.revokeObjectURL(url);
}
async function vendorPortalCall<T>(
token: string,
path: string,
@ -131,4 +192,7 @@ export const vendorPortalApi = {
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
{ method: "POST" },
),
uploadDocument: uploadVendorDocument,
downloadDocument: downloadVendorDocument,
};

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