Merge branch 'dev' into feature/wo-table-advanced-search

This commit is contained in:
Alexandre Brandizzi 2026-07-24 17:54:52 -03:00 • committed by GitHub
commit 143b8c647b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 5330 additions and 383 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.

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

@ -0,0 +1,32 @@
{
"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/v/[token]/dispatch/[id].tsx",
"maxLines": 651,
"reason": "Vendor-portal dispatch detail route; legacy page pending decomposition."
},
{
"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

@ -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>

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

@ -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

@ -88,7 +88,7 @@ export default function WorkOrdersListPage() {
scopeTotalCount={scopeTotalCount}
/>
{filters.advApplied && (
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
@ -126,7 +126,7 @@ export default function WorkOrdersListPage() {
vendorTechs={vendorTechs}
/>
{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

@ -3,6 +3,7 @@ import { Link, useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
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,
@ -63,7 +64,7 @@ function ChecklistSection({
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText}
</label>
{item.isCompleted && item.completedAt && (
{item.isCompleted && Boolean(item.completedAt) && (
<span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)}
</span>
@ -224,7 +225,7 @@ function UpliftRequestsSection({
</span>
</span>
</div>
{request.vendorReason && (
{Boolean(request.vendorReason) && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() {
}
if (error && !data) {
return <div className="vp-error">{error.message}</div>;
return (
<Text variant="error" className="vp-error">
{error.message}
</Text>
);
}
if (!data) return null;
@ -457,20 +462,20 @@ export default function VendorPortalDispatchPage() {
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2>{data.dispatchNumber}</h2>
<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?.internalWONumber && (
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{data.poNumber && (
{Boolean(data.poNumber) && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
@ -482,7 +487,7 @@ export default function VendorPortalDispatchPage() {
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
{data.scheduledDate && (
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
@ -490,9 +495,11 @@ export default function VendorPortalDispatchPage() {
)}
</div>
{data.location && (
{data.location != null && (
<>
<h3 style={{ marginTop: 16 }}>Location</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
@ -507,9 +514,11 @@ export default function VendorPortalDispatchPage() {
</>
)}
{(data.description || data.workOrder?.description) && (
{Boolean(data.description || data.workOrder?.description) && (
<>
<h3 style={{ marginTop: 16 }}>Description</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Checklist</h3>
<Text variant="heading">Checklist</Text>
<ChecklistSection
token={token}
dispatchId={dispatchId}
@ -588,7 +597,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<Text variant="heading">NTE Uplift Requests</Text>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
@ -600,7 +609,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<Text variant="heading">Customer Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -612,7 +621,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<Text variant="heading">Vendor Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -624,7 +633,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Comments</h3>
<Text variant="heading">Comments</Text>
<CommentsSection
token={token}
dispatchId={dispatchId}

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

@ -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

@ -3,23 +3,59 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapVendor,
mapVendorDeactivationImpact,
mapVendorFacets,
mapVendorListItems,
mapVendorPortalToken,
mapVendorToBackend,
} from "@/domain/vendors/mappers/vendor-mapper";
import type {
Vendor,
VendorDeactivationImpact,
VendorFacets,
VendorPortalToken,
VendorsListParams,
VendorsListResult,
} from "@/domain/vendors/types/vendor";
function pushRepeated(
searchParams: Record<string, string | number>,
key: string,
values: string[] | undefined,
): void {
if (!values || values.length === 0) return;
values.forEach((value, index) => {
if (!value) return;
searchParams[`${key}[${index}]`] = value;
});
}
export interface VendorFacetsParams {
isActive?: boolean;
}
function buildFacetsParams(params?: VendorFacetsParams): Record<string, string> {
const searchParams: Record<string, string> = {};
if (params?.isActive !== undefined) {
searchParams.isActive = String(params.isActive);
}
return searchParams;
}
function buildListParams(params: VendorsListParams): Record<string, string | number> {
return {
const searchParams: Record<string, string | number> = {
search: params.search ?? "",
page: params.page ?? 1,
pageSize: params.pageSize ?? 12,
};
if (params.isActive !== undefined) {
searchParams.isActive = String(params.isActive);
}
pushRepeated(searchParams, "companies", params.companies);
pushRepeated(searchParams, "trades", params.trades);
pushRepeated(searchParams, "locations", params.locations);
pushRepeated(searchParams, "jobBuckets", params.jobBuckets);
return searchParams;
}
export const vendorsApi = {
@ -39,6 +75,13 @@ export const vendorsApi = {
};
},
getFacets: async (params?: VendorFacetsParams): Promise<VendorFacets> => {
const data = await apiGet<unknown>(API_PATHS.vendor.facets, {
searchParams: buildFacetsParams(params),
});
return mapVendorFacets(handleApiResponse(data));
},
getById: async (id: string | number): Promise<Vendor> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}`);
return mapVendor(handleApiResponse(data));
@ -61,6 +104,13 @@ export const vendorsApi = {
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
},
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
const data = await apiGet<unknown>(
`${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`,
);
return mapVendorDeactivationImpact(handleApiResponse(data));
},
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
return mapVendorPortalToken(handleApiResponse(data));

View file

@ -1,4 +1,17 @@
import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor";
import type {
Vendor,
VendorDeactivationImpact,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
VendorFacets,
VendorListItem,
VendorOpenWorkOrderSummary,
VendorPortalToken,
VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
@ -13,12 +26,69 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
return 0;
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") return value;
return "";
}
function readCompanyId(record: Record<string, unknown>): string | number | null {
const value = record.companyId ?? record.CompanyId;
if (typeof value === "string") return value.trim() === "" ? null : value;
if (typeof value === "number" && Number.isFinite(value)) return value;
return null;
}
function readNumericCompanyId(record: Record<string, unknown>): number | null {
const value = readCompanyId(record);
if (typeof value === "number") return value;
if (typeof value !== "string" || value.trim() === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function readPreferredContact(
record: Record<string, unknown>,
...keys: string[]
): VendorPreferredContact {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
const normalized = value.trim();
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
return normalized as VendorPreferredContact;
}
const lowered = normalized.toLowerCase();
if (lowered === "phone" || lowered === "call") return "Phone";
if (lowered === "email" || lowered === "e-mail") return "Email";
if (lowered === "text" || lowered === "sms") return "Text";
}
}
return "Phone";
}
export function toCanonicalPhone(value: unknown): string {
const original = String(value ?? "");
let digits = original.replace(/\D+/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
digits = digits.slice(1);
}
if (digits.length !== 10) return original;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
@ -31,12 +101,20 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
const item = asRecord(raw);
return {
id: readId(item),
companyId: readCompanyId(item),
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
}
@ -48,32 +126,44 @@ export function mapVendor(raw: unknown): Vendor {
const item = asRecord(raw);
return {
id: readId(item) || undefined,
companyId: readCompanyId(item),
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
}
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
const item = asRecord(vendor);
const companyName = readString(item, "companyName", "CompanyName");
const payload: Record<string, unknown> = {
companyName: item.companyName ?? "",
contactName: item.contactName ?? "",
email: item.email ?? "",
phone: item.phone ?? "",
address: item.address ?? "",
city: item.city ?? "",
state: item.state ?? "",
zip: item.zip ?? "",
tradeSpecialties: item.tradeSpecialties ?? "",
isActive: item.isActive ?? true,
companyId: readNumericCompanyId(item),
companyName,
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
};
const id = item.id ?? item.Id;
if (typeof id === "string" || typeof id === "number") {
@ -82,6 +172,63 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
return payload;
}
function mapFacetCompany(raw: unknown): VendorFacetCompany {
const item = asRecord(raw);
return {
companyId: readCompanyId(item),
name: readString(item, "name", "Name"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
email: readString(item, "email", "Email"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
};
}
function mapFacetLocation(raw: unknown): VendorFacetLocation {
const item = asRecord(raw);
const city = readString(item, "city", "City");
const state = readString(item, "state", "State");
const label = readString(item, "label", "Label");
return {
city,
state,
label: label || [city, state].filter(Boolean).join(", "),
};
}
function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket {
const item = asRecord(raw);
return {
id: readString(item, "id", "Id"),
label: readString(item, "label", "Label"),
};
}
export function mapVendorFacets(raw: unknown): VendorFacets {
const item = asRecord(raw);
const companiesRaw = item.companies ?? item.Companies;
const tradesRaw = item.trades ?? item.Trades;
const locationsRaw = item.locations ?? item.Locations;
const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets;
return {
companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [],
trades: Array.isArray(tradesRaw)
? tradesRaw
.map((entry) => {
if (typeof entry === "string") return entry;
return readString(asRecord(entry), "name", "Name", "value", "Value", "label", "Label");
})
.filter(Boolean)
: [],
locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [],
jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [],
};
}
export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
const item = asRecord(raw);
const portalUrl = readString(item, "portalUrl", "PortalUrl");
@ -93,3 +240,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
};
}
function mapOpenWorkOrder(raw: unknown): VendorOpenWorkOrderSummary {
const item = asRecord(raw);
const workOrderId = item.workOrderId ?? item.WorkOrderId;
const dispatchId = item.dispatchId ?? item.DispatchId;
return {
workOrderId:
typeof workOrderId === "string" || typeof workOrderId === "number" ? workOrderId : "",
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle"),
status: readString(item, "status", "Status"),
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
dispatchId:
typeof dispatchId === "string" || typeof dispatchId === "number" ? dispatchId : undefined,
};
}
export function mapVendorDeactivationImpact(raw: unknown): VendorDeactivationImpact {
const item = asRecord(raw);
const openWorkOrdersRaw = item.openWorkOrders ?? item.OpenWorkOrders;
const rawId = item.vendorId ?? item.VendorId ?? item.id ?? item.Id;
const vendorId: string | number =
typeof rawId === "string" || typeof rawId === "number" ? rawId : "";
return {
vendorId,
canDeactivate: readBool(item, "canDeactivate", "CanDeactivate"),
openWorkOrders: Array.isArray(openWorkOrdersRaw) ? openWorkOrdersRaw.map(mapOpenWorkOrder) : [],
};
}

View file

@ -1,16 +1,42 @@
import { z } from "zod";
import {
VENDOR_PREFERRED_CONTACT_VALUES,
type VendorPreferredContact,
} from "@/domain/vendors/types/vendor";
const httpsUrl = z
.string()
.trim()
.refine(
(value) => value === "" || /^https:\/\/.+/i.test(value),
"Google Maps URL must use HTTPS",
);
const northAmericanPhone = z.string().refine((value) => {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
}, "Enter a 10-digit phone number");
export const vendorSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().optional(),
email: z.string().min(1, "Email is required").email("Invalid email"),
phone: z.string().optional(),
companyName: z.string().min(1, "Company is required"),
contactName: z.string().min(1, "Technician name is required"),
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
phone: northAmericanPhone.optional(),
companyPhone: northAmericanPhone.optional(),
googleMapsUrl: httpsUrl.optional(),
notes: z.string().optional(),
totalJobs: z.number().optional(),
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zip: z.string().optional(),
tradeSpecialties: z.string().optional(),
isActive: z.boolean().optional(),
companyId: z.union([z.string(), z.number()]).nullable().optional(),
preferredContact: z
.enum(VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]])
.optional(),
});
export type VendorFormValues = z.infer<typeof vendorSchema>;

View file

@ -1,31 +1,54 @@
export type VendorPreferredContact = "Phone" | "Email" | "Text";
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
export interface VendorListItem {
id: string | number;
companyId: string | number | null;
companyName: string;
contactName: string;
email: string;
phone: string;
companyPhone: string;
googleMapsUrl: string;
notes: string;
totalJobs: number;
city: string;
state: string;
tradeSpecialties: string;
isActive: boolean;
preferredContact: VendorPreferredContact;
}
export interface Vendor {
id?: string | number;
companyId: string | number | null;
companyName: string;
contactName: string;
email: string;
phone: string;
companyPhone: string;
googleMapsUrl: string;
notes: string;
totalJobs: number;
address: string;
city: string;
state: string;
zip: string;
tradeSpecialties: string;
isActive: boolean;
preferredContact: VendorPreferredContact;
}
export interface VendorsListParams {
search?: string;
isActive?: boolean;
page?: number;
pageSize?: number;
companies?: string[];
trades?: string[];
locations?: string[];
jobBuckets?: string[];
}
export interface VendorsListResult {
@ -35,9 +58,54 @@ export interface VendorsListResult {
pageSize: number;
}
export interface VendorFacetCompany {
companyId: string | number | null;
name: string;
companyPhone: string;
email: string;
address: string;
city: string;
state: string;
zip: string;
googleMapsUrl: string;
}
export interface VendorFacetLocation {
city: string;
state: string;
label: string;
}
export interface VendorFacetJobBucket {
id: string;
label: string;
}
export interface VendorFacets {
companies: VendorFacetCompany[];
trades: string[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
}
export interface VendorPortalToken {
portalUrl: string;
issuedAt: string;
expiresAt: string;
lastUsedAt?: string;
}
export interface VendorOpenWorkOrderSummary {
workOrderId: string | number;
workOrderNumber: string;
workOrderTitle: string;
status: string;
scheduledDate?: string;
dispatchId?: string | number;
}
export interface VendorDeactivationImpact {
vendorId: string | number;
canDeactivate: boolean;
openWorkOrders: VendorOpenWorkOrderSummary[];
}

View file

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

View file

@ -10,10 +10,10 @@ export function useDeleteVendor(): UseMutationResult<void, Error, string | numbe
mutationFn: (id: string | number) => vendorsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor deleted");
toast.success("Vendor deactivated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete vendor");
toast.error(error.message || "Failed to deactivate vendor");
},
});
}

View file

@ -1,5 +1,4 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
@ -12,7 +11,6 @@ interface UpdateVendorInput {
}
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
@ -21,7 +19,6 @@ export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendor
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.detail(variables.id) });
toast.success("Vendor updated");
navigate("/vendors");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor");

View file

@ -0,0 +1,22 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorDeactivationImpact } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useVendorDeactivationImpact(
id: string | number | undefined,
): UseQueryResult<VendorDeactivationImpact, Error> {
return useQuery({
queryKey: queryKeys.vendors.deactivationImpact(id ?? ""),
queryFn: () => vendorsApi.getDeactivationImpact(requireParam(id)),
enabled: id !== undefined && id !== null && id !== "",
staleTime: 0,
});
}
function requireParam(id: string | number | undefined): string | number {
if (id === undefined || id === null || id === "") {
throw new Error("Vendor id is required");
}
return id;
}

View file

@ -0,0 +1,13 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { vendorsApi, type VendorFacetsParams } from "@/domain/vendors/api/vendors-api";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useVendorFacets(isActive?: boolean): UseQueryResult<VendorFacets, Error> {
const params: VendorFacetsParams | undefined = isActive !== undefined ? { isActive } : undefined;
return useQuery({
queryKey: queryKeys.vendors.facets(isActive),
queryFn: () => vendorsApi.getFacets(params),
staleTime: 60_000,
});
}

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
@ -37,6 +37,7 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
WorkOrderVendorUpdatePayload,
VendorDropdownItem,
WorkOrderComment,
WorkOrderDetail,
@ -294,6 +295,13 @@ export const workOrdersApi = {
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
},
updateVendorFromWorkOrder: async (
vendorId: string | number,
payload: WorkOrderVendorUpdatePayload,
): Promise<void> => {
await apiPut<unknown>(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload);
},
addDispatchComment: async (
dispatchId: string | number,
text: string,

View file

@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
return {
id: readId(item),
vendorId: readId({ id: item.vendorId ?? item.VendorId }),
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
vendorName: readString(item, "vendorName", "VendorName"),
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
status: readString(item, "status", "Status"),
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),

View file

@ -1,13 +1,24 @@
export interface WorkOrderDispatchSummary {
id: string | number;
vendorId: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export interface WorkOrderVendorUpdatePayload {
workOrderId: number;
contactName?: string;
preferredContact: "Phone" | "Email" | "Text";
phone?: string;
email?: string;
notes?: string;
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;

View file

@ -7,6 +7,7 @@ export type {
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
WorkOrderVendorUpdatePayload,
} from "./dispatch";
export type {

View file

@ -11,6 +11,7 @@ import type {
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderVendorUpdatePayload,
} from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput {
userName: string;
}
interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
@ -89,6 +95,26 @@ export function useUpdateWorkOrder(
});
}
export function useUpdateWorkOrderVendor(
workOrderId: string | number,
): UseMutationResult<void, Error, UpdateWorkOrderVendorInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vendorId, payload }) =>
workOrdersApi.updateVendorFromWorkOrder(vendorId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor contact updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor contact");
},
});
}
export function useAddWorkOrderComment(
workOrderId: string | number,
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {

View file

@ -56,8 +56,11 @@ export const queryKeys = {
list: (params?: Record<string, unknown>) =>
[...queryKeys.vendors.all, "list", params ?? {}] as const,
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
portalToken: (vendorId: string | number) =>
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
deactivationImpact: (vendorId: string | number) =>
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
},
calendar: {
all: ["calendar"] as const,

View file

@ -4,28 +4,28 @@ import type { RouteObject } from "react-router";
import type { RouteModule } from "@/routing/route-module";
function Modals({ modalRoutes }: { modalRoutes: Record<string, ComponentType> }) {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
export function createAppShellRoute(
appLayout: RouteModule | undefined,
modalRoutes: Record<string, ComponentType>,
): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> {
const AppLayout = appLayout?.default ?? Outlet;
function Modals() {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
function Layout() {
return (
<>
<AppLayout />
<Modals modalRoutes={modalRoutes} />
<Modals />
</>
);
}

View file

@ -0,0 +1,205 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
import { renderWithProviders } from "@/test/test-utils";
const companies: VendorFacetCompany[] = [
{
companyId: "co-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
},
];
const emptyForm: VendorFormValues = {
companyName: "",
contactName: "Tech Person",
email: "",
phone: "",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
companyId: null,
preferredContact: "Phone",
};
function Harness({
defaultValues,
resetTarget,
}: {
defaultValues: VendorFormValues;
resetTarget: VendorFormValues;
}) {
const { control, reset, setValue } = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
});
const onCompanySelected = (company: VendorFacetCompany | null) => {
if (!company) return;
setValue("companyId", company.companyId);
setValue("companyPhone", company.companyPhone);
setValue("email", company.email);
setValue("address", company.address);
setValue("city", company.city);
setValue("state", company.state);
setValue("zip", company.zip);
setValue("googleMapsUrl", company.googleMapsUrl);
};
const companyId = useWatch({ control, name: "companyId" });
return (
<>
<VendorFormFields
control={control}
errors={{} as FieldErrors<VendorFormValues>}
companies={companies}
tradeOptions={[]}
onCompanySelected={onCompanySelected}
/>
<output data-testid="company-id">{String(companyId ?? "")}</output>
<button type="button" onClick={() => reset(resetTarget)}>
reset-form
</button>
</>
);
}
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
const companyIdOutput = () => screen.getByTestId("company-id");
describe("VendorFormFields company autocomplete state", () => {
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Leftover Co");
expect(companyField()).toHaveValue("Leftover Co");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("");
});
it("shows the switched company value instead of previously typed text when the form is reset", async () => {
renderWithProviders(
<Harness
defaultValues={emptyForm}
resetTarget={{ ...emptyForm, companyName: "Beta LLC", contactName: "Other Tech" }}
/>,
{ withAuth: false },
);
await userEvent.type(companyField(), "Acme");
expect(companyField()).toHaveValue("Acme");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("Beta LLC");
});
it("reflects the company provided by an external reset even when nothing was typed", async () => {
renderWithProviders(
<Harness
defaultValues={{ ...emptyForm, companyName: "Acme Corp" }}
resetTarget={{ ...emptyForm, companyName: "Beta LLC" }}
/>,
{ withAuth: false },
);
expect(companyField()).toHaveValue("Acme Corp");
await userEvent.click(screen.getByRole("button", { name: "reset-form" }));
expect(companyField()).toHaveValue("Beta LLC");
});
it("updates the displayed company as the user types (freeSolo)", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Acme");
expect(companyField()).toHaveValue("Acme");
});
it("selects a company option, autofills company details, and shows the company name", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Gate");
const option = await screen.findByRole("option", { name: "Gateway Plumbing" });
await userEvent.click(option);
expect(companyField()).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
});
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Gate");
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
expect(companyIdOutput()).toHaveTextContent("co-1");
await userEvent.type(companyField(), "!");
expect(companyIdOutput()).toHaveTextContent("");
});
it("keeps companyId null for free-typed company text that matches no option", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
await userEvent.type(companyField(), "Independent Vendor LLC");
expect(companyIdOutput()).toHaveTextContent("");
});
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
await userEvent.type(phone, "3145550198");
expect(phone).toHaveValue("(314) 555-0198");
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
await userEvent.type(companyPhone, "3145550100");
expect(companyPhone).toHaveValue("(314) 555-0100");
});
it("exposes a preferred contact selector with Phone, Email, and Text options", () => {
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
withAuth: false,
});
expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,163 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import VendorsListPage from "@/app/(protected)/vendors/index";
import { renderWithProviders } from "@/test/test-utils";
const useVendorsList = vi.fn();
const useVendorDetail = vi.fn();
const useVendorDeactivationImpact = vi.fn();
const mutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args),
}));
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
useDeleteVendor: () => ({ mutate, isPending: false }),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const activeVendor = {
id: 1,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
email: "dispatch@gatewayplumbing.com",
phone: "(314) 555-0198",
city: "St. Louis",
state: "MO",
tradeSpecialties: "Backflow Preventers",
isActive: true,
preferredContact: "Email",
};
const inactiveVendor = {
...activeVendor,
id: 2,
companyName: "Drain Cleaning Bros",
isActive: false,
};
function result(items: (typeof activeVendor)[], totalCount: number) {
return {
data: { items, totalCount, page: 1, pageSize: 12 },
isLoading: false,
isFetching: false,
error: null,
};
}
function setupDefaults() {
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
useVendorDeactivationImpact.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
});
}
describe("VendorsListPage", () => {
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
...activeVendor,
id: 100 + index,
companyName: `Vendor ${index + 1}`,
}));
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], params.isActive ? 25 : 0);
return result(pageVendors, 25);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
});
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null });
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const row = screen.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
row.focus();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
});
it("blocks deactivation when the preflight reports open work orders", async () => {
setupDefaults();
useVendorDeactivationImpact.mockReturnValue({
data: {
vendorId: 1,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
},
],
},
isLoading: false,
error: null,
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
expect(
screen.getByText(/cannot be deactivated because it still has open work orders/),
).toBeInTheDocument();
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
expect(mutate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,82 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Text } from "@/components/ui/text";
import { renderWithProviders } from "@/test/test-utils";
describe("Text", () => {
it("maps visual variants to semantic elements", () => {
renderWithProviders(
<>
<Text variant="display">Page title</Text>
<Text variant="description">Supporting copy</Text>
</>,
{ withAuth: false },
);
expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument();
expect(screen.getByText("Supporting copy").tagName).toBe("P");
});
it("does not render conditional text when its condition is false", () => {
renderWithProviders(
<Text when={false} variant="body">
Saved
</Text>,
{ withAuth: false },
);
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
});
it("keeps the feedback live region mounted so toggling its content is announceable", () => {
const { rerender } = renderWithProviders(
<Text variant="feedback" when={false}>
Saved
</Text>,
{ withAuth: false },
);
const region = screen.getByRole("status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("aria-atomic", "true");
expect(region).toBeEmptyDOMElement();
rerender(
<Text variant="feedback" when={true}>
Saved
</Text>,
);
expect(screen.getByRole("status")).toBe(region);
expect(region).toHaveTextContent("Saved");
});
it("gives error and feedback messages accessible live-region semantics", () => {
renderWithProviders(
<>
<Text variant="error">Upload failed</Text>
<Text variant="feedback">Uploading</Text>
</>,
{ withAuth: false },
);
const alert = screen.getByRole("alert");
const status = screen.getByRole("status");
expect(alert).toHaveTextContent("Upload failed");
expect(status).toHaveTextContent("Uploading");
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveAttribute("aria-atomic", "true");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
});
it("does not attach live-region semantics to non-feedback variants", () => {
renderWithProviders(<Text variant="body">Saved</Text>, { withAuth: false });
const node = screen.getByText("Saved");
expect(node).not.toHaveAttribute("role");
expect(node).not.toHaveAttribute("aria-live");
expect(node).not.toHaveAttribute("aria-atomic");
});
});

View file

@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiDelete = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiDelete: (...args: unknown[]) => apiDelete(...args),
}));
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
describe("vendorsApi.getList", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPut.mockReset();
apiDelete.mockReset();
apiGet.mockResolvedValue({
Data: [
{
Id: 7,
CompanyId: "co-1",
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
City: "St. Louis",
State: "MO",
IsActive: false,
PreferredContact: "Email",
},
],
TotalCount: 1,
});
});
it("forwards the status filter and maps list location fields", async () => {
const result = await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 });
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
});
expect(result.items[0]).toMatchObject({
city: "St. Louis",
state: "MO",
isActive: false,
companyId: "co-1",
preferredContact: "Email",
});
});
it("forwards all selected facets as repeated indexed query parameters", async () => {
await vendorsApi.getList({
companies: ["Gateway Plumbing", "Metro HVAC"],
trades: ["Plumbing"],
locations: ["St. Louis, MO"],
jobBuckets: ["50-99"],
});
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
searchParams: {
search: "",
page: 1,
pageSize: 12,
"companies[0]": "Gateway Plumbing",
"companies[1]": "Metro HVAC",
"trades[0]": "Plumbing",
"locations[0]": "St. Louis, MO",
"jobBuckets[0]": "50-99",
},
});
});
it("maps company profiles and labels returned by the facets endpoint", async () => {
apiGet.mockResolvedValueOnce({
data: {
companies: [
{
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
},
],
trades: ["Plumbing"],
locations: [{ city: "St. Louis", state: "MO" }],
jobBuckets: [{ id: "50-99", label: "50–99" }],
},
});
const facets = await vendorsApi.getFacets();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
expect(facets.companies[0]).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
googleMapsUrl: "https://maps.google.com/gateway",
});
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
expect(facets.jobBuckets[0]).toEqual({ id: "50-99", label: "50–99" });
});
it("serializes the requested facet scope as an isActive query parameter", async () => {
apiGet.mockResolvedValue({
data: { companies: [], trades: [], locations: [], jobBuckets: [] },
});
await vendorsApi.getFacets({ isActive: true });
await vendorsApi.getFacets({ isActive: false });
expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.facets, {
searchParams: { isActive: "true" },
});
expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.facets, {
searchParams: { isActive: "false" },
});
});
it("sends all editable prototype fields when creating and updating", async () => {
const values = {
companyId: "42",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
phone: "+1 314-555-0198",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
tradeSpecialties: "Plumbing, HVAC",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
totalJobs: 72,
isActive: true,
preferredContact: "Text",
};
apiPost.mockResolvedValueOnce({ data: values });
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
await vendorsApi.create(values);
await vendorsApi.update(7, { ...values, isActive: false });
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({
companyName: "Gateway Plumbing",
notes: values.notes,
companyId: 42,
preferredContact: "Text",
phone: "(314) 555-0198",
companyPhone: "(314) 555-0100",
}),
);
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }),
);
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
});
it("sends a null companyId when free-text company has no company link", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyName: "Independent Vendor LLC",
contactName: "Indy Tech",
preferredContact: "Phone",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }),
);
});
it("does not send a non-numeric company id to the integer backend contract", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyId: null }),
);
});
it("preserves a legacy phone on read and unrelated write", async () => {
apiGet.mockResolvedValueOnce({
data: {
id: 7,
companyName: "Legacy Vendor",
contactName: "Taylor",
phone: "555-1234 ext 9",
},
});
apiPut.mockResolvedValueOnce({ data: {} });
const vendor = await vendorsApi.getById(7);
expect(vendor.phone).toBe("555-1234 ext 9");
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
);
});
it("maps the deactivation-impact payload including linked work orders", async () => {
apiGet.mockResolvedValueOnce({
data: {
vendorId: 7,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
scheduledDate: "2025-01-02",
dispatchId: 501,
},
],
},
});
const impact = await vendorsApi.getDeactivationImpact(7);
expect(apiGet).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`,
);
expect(impact).toEqual({
vendorId: 7,
canDeactivate: false,
openWorkOrders: [
{
workOrderId: 101,
workOrderNumber: "WO-101",
workOrderTitle: "Boiler repair",
status: "Dispatched",
scheduledDate: "2025-01-02",
dispatchId: 501,
},
],
});
});
it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => {
apiDelete.mockResolvedValueOnce(undefined);
await vendorsApi.delete(7);
expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`);
const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } });
apiDelete.mockRejectedValueOnce(conflict);
await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict");
});
});

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
const validVendor = {
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
email: "dispatch@gateway.test",
phone: "(314) 555-0198",
googleMapsUrl: "https://maps.google.com/gateway",
};
describe("vendorSchema", () => {
it("requires both the company and technician identity", () => {
const result = vendorSchema.safeParse({ ...validVendor, companyName: "", contactName: "" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.flatten().fieldErrors.companyName).toContain("Company is required");
expect(result.error.flatten().fieldErrors.contactName).toContain(
"Technician name is required",
);
}
});
it("accepts an empty optional email and HTTPS map URL", () => {
expect(vendorSchema.safeParse({ ...validVendor, email: "" }).success).toBe(true);
});
it("rejects malformed email and non-HTTPS map links", () => {
expect(
vendorSchema.safeParse({
...validVendor,
email: "not-an-email",
googleMapsUrl: "http://maps.google.com/gateway",
}).success,
).toBe(false);
});
it("accepts canonicalizable North American phones and rejects partials", () => {
expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
});
it("accepts a nullable companyId and a known preferred contact value", () => {
expect(
vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success,
).toBe(true);
expect(
vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success,
).toBe(true);
});
it("rejects an unknown preferred contact value", () => {
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
});
});

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