mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge remote-tracking branch 'origin/dev' into codex/pr43-dev-integration
# Conflicts: # src/app/(protected)/workorders/index.tsx # src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
This commit is contained in:
commit
caecc05b68
130 changed files with 8564 additions and 1232 deletions
27
.github/workflows/ci.yaml
vendored
27
.github/workflows/ci.yaml
vendored
|
|
@ -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
81
AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# AGENTS.md — frontend mandatory conventions
|
||||
|
||||
This file is the binding entry point for **every contributor — human or AI coding
|
||||
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
|
||||
frontend). It makes the validated React/TypeScript conventions mandatory and
|
||||
points to the operational documents and executable gates that enforce them.
|
||||
|
||||
Read these before writing or reviewing code. They override generic "best
|
||||
practice" suggestions from any agent or model. This repository contract may
|
||||
strengthen, but never weaken, the workspace-level `AGENTS.md`.
|
||||
|
||||
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
|
||||
command, and the no-false-pass guarantees.
|
||||
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
|
||||
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
|
||||
data access, security, maintainability) with evidence and thresholds.
|
||||
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
|
||||
contract (exact-head review, board regression inventory, behavior-based
|
||||
testing, security/performance/Big-O review, no style-only comments).
|
||||
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
|
||||
detailed rationale for the conditional-rendering and typography rules.
|
||||
|
||||
## One command to run every gate
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||
`governance` job, so a green local run mirrors CI.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
These are already enforced by lint/build or the governance script. Disabling,
|
||||
baselining, or per-line-suppressing them is forbidden (see exceptions below).
|
||||
|
||||
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
|
||||
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
|
||||
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
|
||||
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
|
||||
the `vp-error` class outside `Text` are rejected.
|
||||
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
|
||||
do not silence it.
|
||||
- **Hooks correctness** — the `react-hooks` recommended rules (including
|
||||
`exhaustive-deps`) run under the zero-warnings gate.
|
||||
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
|
||||
has a frozen per-file cap that may only decrease.
|
||||
- **Changed-file maintainability** — changed TS/TSX must meet
|
||||
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
|
||||
|
||||
## How to add / change a convention
|
||||
|
||||
1. Land it **green**: a new or tightened rule must ship with the codebase passing
|
||||
it (a migration in the same change), not as a warning-only backlog.
|
||||
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
|
||||
new baseline debt or raise a frozen cap.
|
||||
3. Document the rule, its threshold, and its evidence in
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||
|
||||
## Exceptions and the ADR process
|
||||
|
||||
Exceptions are **not granted by disabling a rule inline**. To deviate:
|
||||
|
||||
1. Record an **Architecture Decision Record** under `docs/adr/`
|
||||
(`NNNN-title.md`: context, decision, consequences, alternatives).
|
||||
2. Existing grandfathered entries may only be removed or have their caps
|
||||
reduced. New entries and cap increases fail the governance gate.
|
||||
3. Get it reviewed like any other change. The ADR + baseline entry is the
|
||||
auditable record; an `eslint-disable` comment is not.
|
||||
|
||||
## Toolchain (do not change without an ADR)
|
||||
|
||||
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
|
||||
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
|
||||
`packageManager`, invoked through corepack). **No new dependencies without an
|
||||
ADR** — prefer the libraries already established (see
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
|
||||
data access).
|
||||
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
|
||||
|
||||
Mandatory React/TypeScript conventions for this repository. Each rule lists how
|
||||
it is enforced. Detailed rationale for rendering and typography lives in
|
||||
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
|
||||
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
|
||||
means it is a review-enforced convention backed by the
|
||||
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
|
||||
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
|
||||
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
|
||||
semantics and TypeScript narrowing with `count > 0`, `value != null`.
|
||||
`{count && <X/>}` renders `0` and is rejected by the type-aware
|
||||
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
|
||||
the approved guard forms.)
|
||||
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
|
||||
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
|
||||
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
|
||||
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
|
||||
`vp-error` class on any non-`Text` element is rejected
|
||||
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
|
||||
bypass the static check.
|
||||
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
|
||||
omitted from `TextProps` so the variant contract (element, tone, live region)
|
||||
cannot be overridden. Use `as` and `tone`.
|
||||
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
|
||||
with `when`; mount `error` (assertive `alert`) on demand.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
- **MUST** use the libraries already established: **React Hook Form** for field
|
||||
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
|
||||
Query** for server reads and mutations (pending/error state, invalidation,
|
||||
retries).
|
||||
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
|
||||
second form convention increases cognitive and dependency cost. Replacing
|
||||
React Hook Form requires an approved repo-wide migration ADR with measured
|
||||
benefits, a codemod/migration plan, and removal of the superseded dependency.
|
||||
- **SHOULD** keep file upload selection/validation in a focused component and
|
||||
drive upload progress/errors/retry through a TanStack Query mutation, not in a
|
||||
route-sized page.
|
||||
|
||||
## State ownership (bounded)
|
||||
|
||||
- **MUST** keep query loading/error/empty state adjacent to the query result;
|
||||
mutation pending/error state in the component that initiated the mutation.
|
||||
- **MUST** compose focused state components instead of accumulating unrelated
|
||||
booleans in a page. Route pages coordinate sections and navigation; reusable
|
||||
sections own their interaction details.
|
||||
- **SHOULD** render errors inline with accessible feedback (`Text
|
||||
variant="error"`); reserve toasts for cross-page outcomes.
|
||||
|
||||
## Data access
|
||||
|
||||
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
|
||||
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
|
||||
break caching and invalidation. (Review-enforced; a future lint rule is
|
||||
tracked as a gap.)
|
||||
- **MUST NOT** materialize large server collections into client state and then
|
||||
filter/sort them in the component when the server (or a memoized, virtualized
|
||||
layer) should own it. Prefer server-side filtering/pagination; if client-side
|
||||
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
|
||||
- **MUST** invalidate the right query keys after a mutation so caches do not
|
||||
show stale data.
|
||||
|
||||
## Security
|
||||
|
||||
- **MUST NOT** surface raw server error payloads, stack traces, or internal
|
||||
identifiers to end users. Map server errors to a safe user-facing message
|
||||
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
|
||||
(Review-enforced.)
|
||||
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
|
||||
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
|
||||
never put a secret in a `VITE_` variable.
|
||||
|
||||
## Hooks, performance, and Big-O
|
||||
|
||||
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
|
||||
under the zero-warnings gate.
|
||||
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
|
||||
data, key lists stably, and prefer server-side filtering for large sets
|
||||
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
|
||||
only when measured to matter (no speculative memoization).
|
||||
|
||||
## Maintainability (measured thresholds)
|
||||
|
||||
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
|
||||
These are conservative code-shape proxies for review focus. File length is not
|
||||
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
|
||||
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
|
||||
judgment.
|
||||
|
||||
| Metric | Threshold | How enforced |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
|
||||
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
|
||||
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
|
||||
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
|
||||
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
|
||||
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
|
||||
|
||||
**Why these are changed-file ratchets, not whole-repo errors:** measured against
|
||||
the current codebase, the maintainability thresholds surface **72 violations
|
||||
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
|
||||
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
|
||||
legacy without a migration. Instead:
|
||||
|
||||
- The **5 oversized files** are explicit grandfathered debt with frozen
|
||||
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
|
||||
grow; new entries and cap increases fail.
|
||||
- The **complexity/function-length/params/depth** rules apply to **changed**
|
||||
TS/TSX on a PR/push, so new and modified code must comply while untouched
|
||||
legacy is not blocked. Bring legacy into compliance when you next touch it.
|
||||
|
||||
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
|
||||
entries) as debt is paid down. Never raise them to make a change pass.
|
||||
54
QUALITY_GATES.md
Normal file
54
QUALITY_GATES.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# QUALITY_GATES.md — executable frontend gates
|
||||
|
||||
The single command that runs **every** gate, locally and in CI:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
|
||||
backlog; rules ship green (see AGENTS.md → How to add a convention).
|
||||
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
|
||||
when type services are unavailable rather than silently claiming safety.
|
||||
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
|
||||
entry, global cap increase, per-file cap increase, or growth beyond a frozen
|
||||
legacy cap fails. Only cap reductions and entry removals are allowed.
|
||||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
28
README.md
28
README.md
|
|
@ -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`)
|
||||
|
|
|
|||
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
|
||||
|
||||
Every PR is reviewed against this contract, by humans and by review agents. The
|
||||
goal is high-signal review: catch real behavior, security, performance, and
|
||||
regression problems — not re-lint what the gates already enforce.
|
||||
|
||||
## 1. Review the exact head
|
||||
|
||||
- **MUST** review the diff at the **current PR head**, not a stale checkout.
|
||||
Re-pull before reviewing if new commits landed; stale approvals are dismissed
|
||||
on push by branch protection.
|
||||
- **MUST** read the full diff of every changed file, including renames and
|
||||
generated/mapper code, not only the "interesting" components.
|
||||
|
||||
## 2. Board-backed regression inventory
|
||||
|
||||
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
|
||||
all visible tickets for this area, not only the current ticket. Behavior that
|
||||
is Done / QA-approved / released is **protected scope**.
|
||||
- **MUST** treat a plausible regression against protected behavior as a
|
||||
**Blocker** until disproven with repo evidence (the accepting tests, the linked
|
||||
PR/release, and a targeted check of the changed code paths).
|
||||
- **MUST NOT** approve if board access or the relevant inventory is missing —
|
||||
say so and block rather than infer a pass. A missing ticket link alone is not
|
||||
a blocker, but known acceptance criteria must still be traced.
|
||||
|
||||
The current Jira workflow has no `Ready for QA` transition. Merged work remains
|
||||
in the documented pre-QA status until QA evidence supports `Done`; do not invent
|
||||
a status or mark unverified work Done.
|
||||
|
||||
## 3. Behavior-based testing
|
||||
|
||||
- **MUST** test through **public behavior** (rendered output, user interactions,
|
||||
query/mutation outcomes), not internal implementation details. Prefer
|
||||
`@testing-library` queries and user-event flows; assert what users observe.
|
||||
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
|
||||
governance script) executes — validate tooling by running the real gates
|
||||
(`npm run verify`), not with assertion-free unit tests.
|
||||
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
|
||||
error/empty state, and any boundary the change introduces.
|
||||
|
||||
## 4. Security review
|
||||
|
||||
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
|
||||
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
|
||||
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
|
||||
`VITE_*` variable carries a secret (it is baked into the bundle).
|
||||
- **SHOULD** check untrusted input is validated (Zod) before use and that
|
||||
dangerously-set HTML / unescaped server strings are not introduced.
|
||||
|
||||
## 5. Performance and Big-O review
|
||||
|
||||
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
|
||||
collections, re-filtering/sorting on every render, unbounded list rendering
|
||||
without virtualization.
|
||||
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
|
||||
the correct keys (no stale cache, no redundant refetch storms).
|
||||
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
|
||||
unstable identities passed to memoized children.
|
||||
|
||||
## 6. No style-only comments
|
||||
|
||||
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||
gates; review is for behavior, correctness, security, and architecture.
|
||||
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
|
||||
evidence-based convention in these docs, and offer a concrete fix or a
|
||||
targeted question. Use GitHub suggestion blocks when safe.
|
||||
|
||||
## 7. Review close-out
|
||||
|
||||
A review is complete when it records, briefly:
|
||||
|
||||
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
|
||||
"no findings".
|
||||
2. Open questions and their owner.
|
||||
3. Which of the above checks were run, and any that were skipped (and why).
|
||||
4. Residual risk, if approving.
|
||||
|
||||
Do not write a monolithic review body or a validation transcript into the PR
|
||||
surface; keep comments inline and high-signal.
|
||||
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Frontend maintainability conventions
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||
state. Use a ternary only when both branches render meaningful alternatives.
|
||||
|
||||
```tsx
|
||||
{
|
||||
error && <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
<Text variant="description" when={Boolean(description)}>
|
||||
{description}
|
||||
</Text>;
|
||||
```
|
||||
|
||||
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||
distinct from real either-or UI decisions.
|
||||
|
||||
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
|
||||
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
|
||||
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
|
||||
TypeScript checker for the type of the left operand and reports unless every union constituent is
|
||||
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
|
||||
fails closed: if type services are unavailable in a governed TSX file it reports rather than
|
||||
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
|
||||
same constraint at that component boundary.
|
||||
|
||||
Approved guard forms (choose by semantics, not as a blind codemod):
|
||||
|
||||
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
|
||||
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
|
||||
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
|
||||
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
|
||||
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
|
||||
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
|
||||
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
|
||||
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
|
||||
narrowing.
|
||||
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
|
||||
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
|
||||
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
|
||||
|
||||
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
|
||||
that needs a non-boolean operand must be rewritten into one of the approved forms above.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||
code, and asynchronous feedback. It owns:
|
||||
|
||||
- semantic HTML for each visual variant;
|
||||
- the display, body, and monospace font families;
|
||||
- default, muted, error, success, and warning tones;
|
||||
- accessible `alert` and `status` live regions for error and feedback text;
|
||||
- one-sided conditional text through `when`.
|
||||
|
||||
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
|
||||
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
|
||||
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
|
||||
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
|
||||
`aria-live` directly.
|
||||
|
||||
Live-region behavior:
|
||||
|
||||
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
|
||||
the polite region exists before its content changes and is announced reliably. Mounting an
|
||||
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
|
||||
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
|
||||
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
|
||||
|
||||
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
|
||||
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
|
||||
|
||||
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
|
||||
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
|
||||
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
|
||||
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
|
||||
static surface only. It cannot resolve dynamic or composed class values
|
||||
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
|
||||
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
Use the libraries already established in the application:
|
||||
|
||||
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||
- Zod owns form validation and inferred form value types.
|
||||
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||
invalidation, and retries where safe.
|
||||
|
||||
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||
benchmarks, a codemod plan, and an approved deprecation path.
|
||||
|
||||
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||
retry state. Do not place upload orchestration in a route-sized page component.
|
||||
|
||||
## Page state
|
||||
|
||||
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||
|
||||
- query loading, error, and empty states stay adjacent to the query result;
|
||||
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||
- route pages coordinate sections and navigation;
|
||||
- reusable sections own their interaction details;
|
||||
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||
|
||||
## Enforcement and rollout
|
||||
|
||||
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
508
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-operations-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor Operations Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function authenticate(page: Page) {
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
}
|
||||
|
||||
test.describe("Vendor operations", () => {
|
||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||
fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "no-vendor-501",
|
||||
type: "NoVendor",
|
||||
severity: "Medium",
|
||||
group: "No Vendor",
|
||||
title: "No vendor assigned — 48h to service",
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
scheduledAt: "2026-07-25T12:00:00Z",
|
||||
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
||||
},
|
||||
{
|
||||
id: "vendor-conflict-801-802",
|
||||
type: "VendorConflict",
|
||||
severity: "Medium",
|
||||
group: "Vendor Conflict",
|
||||
title: "Vendor has overlapping appointments",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
workOrders: [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T13:00:00Z",
|
||||
end: "2026-07-25T15:00:00Z",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ label: "WO-501", href: "/workorders/501" },
|
||||
{ label: "WO-502", href: "/workorders/502" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/notifications");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/edit/501?assignVendor=1",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/501",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/502",
|
||||
);
|
||||
});
|
||||
|
||||
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
const requestedUrls: string[] = [];
|
||||
await page.route("**/api/vendor-operations/insights*", async (route) => {
|
||||
requestedUrls.push(route.request().url());
|
||||
const { pathname } = new URL(route.request().url());
|
||||
if (pathname.endsWith(".csv")) {
|
||||
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith(".pdf")) {
|
||||
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
vendorId: 7,
|
||||
vendor: "Gateway Plumbing",
|
||||
completedJobs: 12,
|
||||
cancellationRefusalRate: 0.1,
|
||||
onTimeArrivalRate: 0.9,
|
||||
onTimeCompletionRate: 0.8,
|
||||
averageCycleHours: 5.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
|
||||
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
|
||||
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
|
||||
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
|
||||
|
||||
const insightsQueryHas = (name: string, value: string) =>
|
||||
requestedUrls.some(
|
||||
(url) =>
|
||||
new URL(url).pathname.endsWith("/insights") &&
|
||||
new URL(url).searchParams.get(name) === value,
|
||||
);
|
||||
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
|
||||
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
|
||||
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Export CSV" }).click();
|
||||
await page.getByRole("button", { name: "Export PDF" }).click();
|
||||
|
||||
const exportRequest = (format: "csv" | "pdf") =>
|
||||
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
|
||||
const exportHasFilters = (url: string | undefined) => {
|
||||
if (!url) return false;
|
||||
const params = new URL(url).searchParams;
|
||||
return (
|
||||
params.get("from") === "2026-01-01" &&
|
||||
params.get("to") === "2026-01-31" &&
|
||||
params.get("trade") === "Plumbing"
|
||||
);
|
||||
};
|
||||
|
||||
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
|
||||
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
|
||||
});
|
||||
|
||||
test("reorders and saves preferred vendors for a site", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
let savedPreferences: unknown;
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Vendor/Dropdown*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
|
||||
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
savedPreferences = route.request().postDataJSON();
|
||||
await fulfillJson(route, savedPreferences);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, [
|
||||
{
|
||||
id: 1,
|
||||
locationId: 10,
|
||||
vendorId: 7,
|
||||
vendorName: "Gateway Plumbing",
|
||||
trade: "Plumbing",
|
||||
sortOrder: 0,
|
||||
isStale: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
locationId: 10,
|
||||
vendorId: 8,
|
||||
vendorName: "Metro HVAC",
|
||||
trade: "HVAC",
|
||||
sortOrder: 1,
|
||||
isStale: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await page.goto("/vendors/preferences");
|
||||
await page.getByLabel("Site").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save preferences" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => savedPreferences)
|
||||
.toEqual([
|
||||
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
|
||||
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
let assignment: unknown;
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 900,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON();
|
||||
await fulfillJson(route, {
|
||||
availabilityStatus: "Available",
|
||||
conflicts: [
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 900,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/new");
|
||||
await page.getByLabel("Title").fill("Boiler repair");
|
||||
await page.getByLabel("Location").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await page.getByLabel("Priority").click();
|
||||
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||
await page.getByLabel("Scheduled").fill("2026-07-25");
|
||||
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
|
||||
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
|
||||
await page.getByLabel("Manual ETA override").check();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => assignment)
|
||||
.toEqual({
|
||||
vendorId: 7,
|
||||
scheduledStart: "2026-07-25T10:00:00.000Z",
|
||||
scheduledEnd: "2026-07-25T12:00:00.000Z",
|
||||
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
|
||||
etaManualOverride: true,
|
||||
});
|
||||
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/workorders\/900$/);
|
||||
});
|
||||
|
||||
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
|
||||
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||
const page = await context.newPage();
|
||||
await authenticate(page);
|
||||
let assignment: Record<string, unknown> | undefined;
|
||||
|
||||
const workOrder = {
|
||||
Id: 901,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
CreatedDate: "2026-07-20",
|
||||
ScheduledDate: "2026-07-25",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [
|
||||
{
|
||||
Id: 44,
|
||||
VendorId: 7,
|
||||
Status: "Acknowledged",
|
||||
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
|
||||
EtaManualOverride: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, { data: workOrder }),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
|
||||
fulfillJson(route, { data: workOrder }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||
});
|
||||
|
||||
await page.goto("/workorders/edit/901");
|
||||
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
|
||||
await page.getByRole("button", { name: "Update" }).click();
|
||||
|
||||
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
|
||||
// The window below straddles the transition: 01:00 is EST (UTC-5) while
|
||||
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
|
||||
// fixed-window assumption remains.
|
||||
const context = await browser.newContext({ timezoneId: "America/New_York" });
|
||||
const page = await context.newPage();
|
||||
await authenticate(page);
|
||||
let assignment: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 902,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
|
||||
assignment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
|
||||
});
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 902,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/new");
|
||||
await page.getByLabel("Title").fill("Boiler repair");
|
||||
await page.getByLabel("Location").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await page.getByLabel("Priority").click();
|
||||
await page.getByRole("option", { name: "Sev 1" }).click();
|
||||
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
|
||||
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
|
||||
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/Contact/GetLocations", (route) =>
|
||||
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
|
||||
);
|
||||
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
|
||||
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/vendor-operations/availability*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Taylor Tech",
|
||||
availabilityStatus: "Available",
|
||||
isPreferred: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 501,
|
||||
WorkerOrderTitle: "Boiler repair",
|
||||
LocationId: 10,
|
||||
Priority: "Sev 1",
|
||||
Status: "Open",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workorders/edit/501?assignVendor=1");
|
||||
|
||||
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
|
||||
await expect(vendorSection).toBeVisible();
|
||||
await expect(vendorSection).toBeFocused();
|
||||
await page.getByLabel("Company + Technician").click();
|
||||
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
|
||||
await expect(option).toContainText("Unknown");
|
||||
});
|
||||
});
|
||||
410
e2e/vendors/vendors.spec.ts
vendored
Normal file
410
e2e/vendors/vendors.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "vendor-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Vendor E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const activeVendors = [
|
||||
{
|
||||
Id: 1,
|
||||
CompanyId: 101,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
Email: "dispatch@gateway.test",
|
||||
Phone: "314-555-0198",
|
||||
CompanyPhone: "314-555-0100",
|
||||
Address: "1 Market St",
|
||||
City: "St. Louis",
|
||||
State: "MO",
|
||||
Zip: "63101",
|
||||
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
TotalJobs: 72,
|
||||
IsActive: true,
|
||||
PreferredContact: "Email",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CompanyId: 102,
|
||||
CompanyName: "Metro HVAC",
|
||||
ContactName: "Maya Chen",
|
||||
Email: "maya@metro.test",
|
||||
Phone: "314-555-0112",
|
||||
CompanyPhone: "314-555-0110",
|
||||
Address: "2 Pine St",
|
||||
City: "Clayton",
|
||||
State: "MO",
|
||||
Zip: "63105",
|
||||
TradeSpecialties: "HVAC",
|
||||
GoogleMapsUrl: "",
|
||||
Notes: "",
|
||||
TotalJobs: 18,
|
||||
IsActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
const inactiveVendors = [
|
||||
{
|
||||
...activeVendors[0],
|
||||
Id: 3,
|
||||
CompanyName: "Drain Cleaning Bros",
|
||||
ContactName: "Drew Banks",
|
||||
TotalJobs: 151,
|
||||
IsActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface MockState {
|
||||
listUrls: string[];
|
||||
createdBody?: Record<string, unknown>;
|
||||
updatedBody?: Record<string, unknown>;
|
||||
deletedId?: string;
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function captureVisual(page: Page, path: string) {
|
||||
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
}
|
||||
|
||||
async function mockVendorApi(
|
||||
page: Page,
|
||||
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
||||
) {
|
||||
const state: MockState = { listUrls: [] };
|
||||
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "Plumbing" },
|
||||
{ id: 2, value: "HVAC" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/Vendor/facets*", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
companies: activeVendors.map((vendor) => ({
|
||||
companyId: vendor.CompanyId,
|
||||
name: vendor.CompanyName,
|
||||
companyPhone: vendor.CompanyPhone,
|
||||
email: vendor.Email,
|
||||
address: vendor.Address,
|
||||
city: vendor.City,
|
||||
state: vendor.State,
|
||||
zip: vendor.Zip,
|
||||
googleMapsUrl: vendor.GoogleMapsUrl,
|
||||
})),
|
||||
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||
locations: [
|
||||
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||
],
|
||||
jobBuckets: [
|
||||
{ id: "under-50", label: "Under 50" },
|
||||
{ id: "50-99", label: "50–99" },
|
||||
{ id: "100-149", label: "100–149" },
|
||||
{ id: "150-plus", label: "150+" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
state.listUrls.push(url.toString());
|
||||
if (options.listError && url.searchParams.get("pageSize") !== "1") {
|
||||
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = url.searchParams.get("isActive") !== "false";
|
||||
const source = isActive ? activeVendors : inactiveVendors;
|
||||
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||
const company = url.searchParams.get("companies[0]");
|
||||
const filtered = source.filter(
|
||||
(vendor) =>
|
||||
(!search ||
|
||||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}`
|
||||
.toLowerCase()
|
||||
.includes(search)) &&
|
||||
(!company || vendor.CompanyName === company),
|
||||
);
|
||||
const isCount = url.searchParams.get("pageSize") === "1";
|
||||
await fulfillJson(route, {
|
||||
Data: isCount ? filtered.slice(0, 1) : filtered,
|
||||
TotalCount: filtered.length,
|
||||
PageNumber: 1,
|
||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
vendorId,
|
||||
canDeactivate: !options.deactivationBlocked,
|
||||
openWorkOrders: options.deactivationBlocked
|
||||
? [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
workOrderTitle: "Emergency boiler repair",
|
||||
status: "Dispatched",
|
||||
scheduledDate: "2026-07-24T14:00:00Z",
|
||||
dispatchId: 801,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||
const id = route.request().url().split("/").pop() ?? "";
|
||||
if (route.request().method() === "PUT") {
|
||||
state.updatedBody = route.request().postDataJSON();
|
||||
await fulfillJson(route, {
|
||||
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
if (options.deleteConflict) {
|
||||
await fulfillJson(
|
||||
route,
|
||||
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||||
409,
|
||||
);
|
||||
return;
|
||||
}
|
||||
state.deletedId = id;
|
||||
await fulfillJson(route, { status: "200" });
|
||||
return;
|
||||
}
|
||||
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
|
||||
await fulfillJson(route, { data: vendor });
|
||||
});
|
||||
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
state.createdBody = route.request().postDataJSON();
|
||||
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test.describe("Vendor directory prototype parity", () => {
|
||||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
for (const heading of [
|
||||
"Company",
|
||||
"Technician",
|
||||
"Location",
|
||||
"Trade",
|
||||
"Phone",
|
||||
"Email",
|
||||
"Total Jobs",
|
||||
"Status",
|
||||
]) {
|
||||
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expect(page.getByText("+1")).toBeVisible();
|
||||
await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
|
||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade...").clear();
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||
await page.getByRole("button", { name: /Company/ }).click();
|
||||
await page.getByPlaceholder("All companies").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||
expect(
|
||||
state.listUrls.some(
|
||||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||
"history will be preserved",
|
||||
);
|
||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||
await expect.poll(() => state.deletedId).toBe("1");
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear all" }).click();
|
||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creates with company autofill, then views and edits every detail field", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||
|
||||
await page.getByLabel("Technician Name").fill("New Technician");
|
||||
await page.getByRole("combobox", { name: "Company" }).click();
|
||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
|
||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
||||
await page.getByRole("button", { name: "Add trade" }).click();
|
||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
|
||||
await page.getByRole("button", { name: "Add trade" }).click();
|
||||
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Text" }).click();
|
||||
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||
expect(state.createdBody).toMatchObject({
|
||||
companyName: "Gateway Plumbing",
|
||||
companyId: 101,
|
||||
contactName: "New Technician",
|
||||
companyPhone: "(314) 555-0100",
|
||||
preferredContact: "Text",
|
||||
tradeSpecialties: "HVAC, Plumbing",
|
||||
notes: "Created in browser E2E",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||
await expect(
|
||||
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
|
||||
).toHaveAttribute("target", "_blank");
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||
|
||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72");
|
||||
await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly");
|
||||
await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E");
|
||||
await detailDrawer.getByRole("switch", { name: "Active" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||
expect(state.updatedBody?.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||
await addButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||
await expect(addButton).toBeFocused();
|
||||
|
||||
await addButton.click();
|
||||
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||
|
||||
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
||||
expect(state.createdBody?.companyId).toBeNull();
|
||||
});
|
||||
|
||||
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
||||
page,
|
||||
}) => {
|
||||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||
expect(blockedState.deletedId).toBeUndefined();
|
||||
|
||||
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
|
||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||
.getByRole("button", {
|
||||
name: /^Deactivate$/,
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||
/open work orders|conflict/i,
|
||||
);
|
||||
expect(racedState.deletedId).toBeUndefined();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockVendorApi(page, { listError: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/Vendor directory unavailable|failed|server error/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||
await expect(filterDrawer).toBeVisible();
|
||||
const box = await filterDrawer.boundingBox();
|
||||
expect(box?.width).toBeLessThanOrEqual(391);
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||
await page.getByRole("button", { name: "Close filters" }).click();
|
||||
});
|
||||
});
|
||||
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "work-order-vendor-e2e-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order E2E Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
test("edits only the vendor assigned to the current work order", async ({ page }) => {
|
||||
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
|
||||
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||
fulfillJson(route, {
|
||||
Id: 501,
|
||||
InternalWONumber: "WO-501",
|
||||
WorkerOrderNumber: "CUSTOMER-501",
|
||||
WorkerOrderTitle: "Emergency boiler repair",
|
||||
Status: "Dispatched",
|
||||
Comments: [],
|
||||
AuditLog: [],
|
||||
Dispatches: [
|
||||
{
|
||||
Id: 801,
|
||||
DispatchNumber: "D-801",
|
||||
VendorId: 7,
|
||||
VendorName: "Gateway Plumbing",
|
||||
VendorEmail: "dispatch@gateway.test",
|
||||
Status: "Sent",
|
||||
DispatchedAt: "2026-07-23T12:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/vendors/7/work-order-update", async (route) => {
|
||||
updateRequest = {
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON() as Record<string, unknown>,
|
||||
};
|
||||
await fulfillJson(route, { data: { id: 7 } });
|
||||
});
|
||||
await page.route("**/api/vendors/7", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
Id: 7,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
PreferredContact: "Email",
|
||||
Phone: "3145550198",
|
||||
Email: "dispatch@gateway.test",
|
||||
Notes: "Preferred after-hours vendor",
|
||||
IsActive: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||
|
||||
await page.goto("/workorders/501");
|
||||
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit vendor" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
|
||||
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
|
||||
await page.getByLabel("Technician name").fill("Taylor Tech");
|
||||
await page.getByLabel("Preferred contact").click();
|
||||
await page.getByRole("option", { name: "Text" }).click();
|
||||
await page.getByLabel("Phone").fill("3145550111");
|
||||
await page.getByLabel("Email").fill("taylor@example.com");
|
||||
await page.getByLabel("Notes").fill("Text before arrival");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
|
||||
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
|
||||
expect(updateRequest?.body).toEqual({
|
||||
workOrderId: 501,
|
||||
contactName: "Taylor Tech",
|
||||
preferredContact: "Text",
|
||||
phone: "(314) 555-0111",
|
||||
email: "taylor@example.com",
|
||||
notes: "Text before arrival",
|
||||
});
|
||||
});
|
||||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
|
||||
* boolean.
|
||||
*
|
||||
* `{value && <Element />}` renders its left operand when it is falsy and
|
||||
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
|
||||
* must be `boolean` (or boolean literals) across the whole union. The rule
|
||||
* asks the TypeScript checker for the type of the left operand and reports
|
||||
* unless every union constituent is boolean-like.
|
||||
*
|
||||
* Type-aware by design: a selector that cannot see operand types would give
|
||||
* false confidence rather than enforcement. If type services are unexpectedly
|
||||
* unavailable in a governed TSX file, the rule fails closed (reports) instead
|
||||
* of silently claiming the expression is safe.
|
||||
*/
|
||||
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
|
||||
|
||||
function isBooleanLikeType(type) {
|
||||
return (type.flags & booleanLikeFlags) !== 0;
|
||||
}
|
||||
|
||||
function isEntirelyBoolean(type) {
|
||||
if (type.isUnion()) {
|
||||
return type.types.every((constituent) => isBooleanLikeType(constituent));
|
||||
}
|
||||
return isBooleanLikeType(type);
|
||||
}
|
||||
|
||||
const transparentExpressionTypes = new Set([
|
||||
"ChainExpression",
|
||||
"ParenthesizedExpression",
|
||||
"TSAsExpression",
|
||||
"TSNonNullExpression",
|
||||
"TSTypeAssertion",
|
||||
]);
|
||||
|
||||
function isInRenderedPosition(node) {
|
||||
let current = node;
|
||||
while (current.parent) {
|
||||
const parent = current.parent;
|
||||
if (parent.type === "JSXExpressionContainer") {
|
||||
return parent.parent?.type !== "JSXAttribute";
|
||||
}
|
||||
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the left operand of `&&` in JSX to be entirely boolean",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonBooleanLeft:
|
||||
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
|
||||
typeServicesUnavailable:
|
||||
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
||||
|
||||
return {
|
||||
LogicalExpression(node) {
|
||||
if (node.operator !== "&&") {
|
||||
return;
|
||||
}
|
||||
if (!isInRenderedPosition(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!services || services.program === null) {
|
||||
context.report({ node, messageId: "typeServicesUnavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
const leftType = services.getTypeAtLocation(node.left);
|
||||
if (!isEntirelyBoolean(leftType)) {
|
||||
context.report({ node: node.left, messageId: "nonBooleanLeft" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
55
eslint-rules/no-vp-error-outside-text.js
Normal file
55
eslint-rules/no-vp-error-outside-text.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
function containsVpErrorToken(node) {
|
||||
if (!node || typeof node !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type === "Literal" && typeof node.value === "string") {
|
||||
return node.value.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
if (node.type === "TemplateElement") {
|
||||
return node.value.raw.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
return Object.entries(node).some(([key, value]) => {
|
||||
if (key === "parent") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsVpErrorToken);
|
||||
}
|
||||
return containsVpErrorToken(value);
|
||||
});
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the Text component for legacy vp-error styling",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useText:
|
||||
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementName = node.parent?.name;
|
||||
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({ node, messageId: "useText" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
|
|
@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
|
|||
import globals from "globals";
|
||||
import 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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ export default defineConfig({
|
|||
use: {
|
||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||
baseURL: "http://localhost:4173",
|
||||
// Pin the browser timezone to UTC so date-to-ISO assertions in the default
|
||||
// create flow are deterministic regardless of host locale. DST-transition
|
||||
// scenarios override this with their own non-UTC context.
|
||||
timezoneId: "UTC",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
|
|
|
|||
27
scripts/governance-baseline.json
Normal file
27
scripts/governance-baseline.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"version": 1,
|
||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||
"maxFileLines": 500,
|
||||
"godfileDebt": [
|
||||
{
|
||||
"path": "src/app/(protected)/workorders/[id].tsx",
|
||||
"maxLines": 797,
|
||||
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
|
||||
},
|
||||
{
|
||||
"path": "src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx",
|
||||
"maxLines": 782,
|
||||
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
|
||||
},
|
||||
{
|
||||
"path": "src/app/(protected)/vendors/index.tsx",
|
||||
"maxLines": 612,
|
||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||
},
|
||||
{
|
||||
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
|
||||
"maxLines": 572,
|
||||
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
|
||||
}
|
||||
]
|
||||
}
|
||||
289
scripts/governance-check.mjs
Normal file
289
scripts/governance-check.mjs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
||||
|
||||
const MAX_FILE_LINES = 500;
|
||||
const MAINTAINABILITY_RULES = [
|
||||
'complexity: ["error", { "max": 20 }]',
|
||||
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
|
||||
'max-params: ["error", 4]',
|
||||
'max-depth: ["error", 4]',
|
||||
];
|
||||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
|
||||
/\.(ts|tsx)$/.test(relativePath) &&
|
||||
!EXCLUDE_DIR.test(relativePath) &&
|
||||
!EXCLUDE_NAME.test(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function gitText(args) {
|
||||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function gitLines(args) {
|
||||
return gitText(args).split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function governedFiles() {
|
||||
const tracked = gitLines(["ls-files"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...tracked, ...untracked])].filter(isGoverned);
|
||||
}
|
||||
|
||||
function lineCount(relativePath) {
|
||||
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function readBaseline() {
|
||||
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function readBaselineAtRef(ref) {
|
||||
try {
|
||||
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function godfileRatchet(baseRef) {
|
||||
const baseline = readBaseline();
|
||||
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
const debtEntries = new Map(
|
||||
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
const files = governedFiles();
|
||||
|
||||
const newDebt = [];
|
||||
const grownDebt = [];
|
||||
for (const file of files) {
|
||||
const lines = lineCount(file);
|
||||
const debtCap = debtEntries.get(file);
|
||||
if (lines > cap && debtCap === undefined) {
|
||||
newDebt.push({ path: file, lines });
|
||||
} else if (debtCap !== undefined && lines > debtCap) {
|
||||
grownDebt.push({ path: file, lines, maxLines: debtCap });
|
||||
}
|
||||
}
|
||||
|
||||
const stale = [];
|
||||
const remaining = [];
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
|
||||
if (lines === -1 || lines <= cap) {
|
||||
stale.push({ path: debtPath, lines });
|
||||
} else {
|
||||
remaining.push({ path: debtPath, lines, maxLines });
|
||||
}
|
||||
}
|
||||
|
||||
const baselineLoosening = [];
|
||||
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
|
||||
if (baseBaseline) {
|
||||
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
if (cap > baseCap) {
|
||||
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
|
||||
}
|
||||
const baseEntries = new Map(
|
||||
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const priorMax = baseEntries.get(debtPath);
|
||||
if (priorMax === undefined) {
|
||||
baselineLoosening.push(`new debt entry: ${debtPath}`);
|
||||
} else if (maxLines > priorMax) {
|
||||
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cap,
|
||||
newDebt,
|
||||
grownDebt,
|
||||
stale,
|
||||
remaining,
|
||||
baselineLoosening,
|
||||
comparedBaseline: Boolean(baseBaseline),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
for (const candidate of ["origin/dev", "origin/main"]) {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", candidate], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
});
|
||||
return candidate;
|
||||
} catch {
|
||||
// candidate ref not present locally; try the next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function changedGovernedFiles(baseRef) {
|
||||
let mergeBase;
|
||||
try {
|
||||
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
|
||||
}
|
||||
|
||||
function maintainabilityGate(files) {
|
||||
if (files.length === 0) {
|
||||
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||
}
|
||||
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
|
||||
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||
const result = spawnSync(
|
||||
eslintBin,
|
||||
[
|
||||
...files,
|
||||
...ruleArgs,
|
||||
"--max-warnings=0",
|
||||
"--no-warn-ignored",
|
||||
"--no-error-on-unmatched-pattern",
|
||||
],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
);
|
||||
return {
|
||||
skipped: false,
|
||||
status: result.status,
|
||||
stdout: result.stdout?.trim() ?? "",
|
||||
stderr: result.stderr?.trim() ?? "",
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function plural(count, word) {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
if (!baseRef) {
|
||||
failures.push(
|
||||
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
gitText(["merge-base", baseRef, "HEAD"]);
|
||||
} catch {
|
||||
failures.push(
|
||||
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log("godfile ratchet: legacy caps may only shrink");
|
||||
const god = godfileRatchet(baseRef);
|
||||
console.log(
|
||||
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
|
||||
);
|
||||
for (const entry of god.remaining) {
|
||||
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.newDebt) {
|
||||
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.grownDebt) {
|
||||
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
if (god.newDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
|
||||
);
|
||||
}
|
||||
if (god.grownDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
|
||||
);
|
||||
}
|
||||
if (god.baselineLoosening.length > 0) {
|
||||
failures.push(
|
||||
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
|
||||
);
|
||||
}
|
||||
if (!god.comparedBaseline) {
|
||||
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
|
||||
}
|
||||
if (god.stale.length > 0) {
|
||||
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
|
||||
for (const entry of god.stale) {
|
||||
console.log(` stale ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (!baseRef) {
|
||||
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
|
||||
} else {
|
||||
const files = changedGovernedFiles(baseRef);
|
||||
console.log(
|
||||
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
|
||||
);
|
||||
if (files === null) {
|
||||
console.log(" failed — base ref could not be resolved against HEAD");
|
||||
} else {
|
||||
const gate = maintainabilityGate(files);
|
||||
if (gate.skipped) {
|
||||
console.log(` skipped — ${gate.reason}`);
|
||||
} else {
|
||||
const clean = gate.status === 0;
|
||||
console.log(
|
||||
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
|
||||
);
|
||||
if (!clean) {
|
||||
if (gate.stdout) console.log(gate.stdout);
|
||||
if (gate.stderr) console.log(gate.stderr);
|
||||
failures.push(
|
||||
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
for (const failure of failures) console.log(` - ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("RESULT: PASS — all governance gates green");
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -109,6 +109,8 @@ export const API_PATHS = {
|
|||
update: "Vendor/Update",
|
||||
delete: "Vendor/Delete",
|
||||
dropdown: "Vendor/Dropdown",
|
||||
facets: "Vendor/facets",
|
||||
deactivationImpact: "deactivation-impact",
|
||||
},
|
||||
calendar: {
|
||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
104
src/app/(protected)/notifications/index.tsx
Normal file
104
src/app/(protected)/notifications/index.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["vendor-operations", "notifications"],
|
||||
queryFn: vendorOperationsApi.notifications,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const groups = data.reduce((result, item) => {
|
||||
const items = result.get(item.group) ?? [];
|
||||
items.push(item);
|
||||
result.set(item.group, items);
|
||||
return result;
|
||||
}, new Map<string, typeof data>());
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
Items requiring attention
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Medium-severity vendor reminders refresh automatically. They do not create banners or
|
||||
toasts.
|
||||
</Typography>
|
||||
</Box>
|
||||
{isLoading && <CircularProgress aria-label="Loading notifications" />}
|
||||
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Typography>No vendor alerts require attention.</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{[...groups.entries()].map(([group, items]) => (
|
||||
<Paper key={group} variant="outlined" className="p-4">
|
||||
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
|
||||
<Typography variant="h6">{group}</Typography>
|
||||
<Chip label={items.length} size="small" />
|
||||
</Stack>
|
||||
<Stack spacing={2}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" className="p-3">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
sx={{ gap: 2, justifyContent: "space-between" }}
|
||||
>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip label={item.severity} color="warning" size="small" />
|
||||
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-1">
|
||||
{item.workOrderNumber ??
|
||||
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
|
||||
</Typography>
|
||||
{item.scheduledAt != null && (
|
||||
<Typography variant="caption">
|
||||
Scheduled {new Date(item.scheduledAt).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
{item.action != null && (
|
||||
<Button component={Link} to={item.action.href} variant="contained">
|
||||
{item.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{item.actions?.map((action) => (
|
||||
<Button
|
||||
key={action.href}
|
||||
component={Link}
|
||||
to={action.href}
|
||||
variant="outlined"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
|
|||
<Stack spacing={3}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
155
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
155
src/app/(protected)/vendors/_components/vendor-create-modal.tsx
vendored
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorFormFields } from "./vendor-form-fields";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
|
||||
interface VendorCreateModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tradeOptions: string[];
|
||||
}
|
||||
|
||||
const defaultValues: VendorFormValues = {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||
const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets();
|
||||
const createVendor = useCreateVendor();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset(defaultValues);
|
||||
setSubmitError(null);
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||
if (!company) return;
|
||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const onSubmit = (values: VendorFormValues) => {
|
||||
setSubmitError(null);
|
||||
createVendor.mutate(values, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSubmitError(error.message || "Failed to create vendor");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const companies = facets?.companies ?? [];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={createVendor.isPending ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
aria-labelledby="vendor-create-dialog-title"
|
||||
slotProps={{ paper: { role: "dialog" } }}
|
||||
>
|
||||
<DialogTitle id="vendor-create-dialog-title">
|
||||
Add Vendor
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
Register a technician and their vendor company.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="mt-1">
|
||||
{Boolean(facetsError) && (
|
||||
<Alert severity="warning">
|
||||
Company autocomplete unavailable. You can still type a company manually.
|
||||
</Alert>
|
||||
)}
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
{isLoadingFacets ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Box>
|
||||
<VendorFormFields
|
||||
control={control}
|
||||
errors={errors}
|
||||
companies={companies}
|
||||
tradeOptions={tradeOptions}
|
||||
onCompanySelected={onCompanySelected}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={createVendor.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createVendor.isPending || !isFormValid}
|
||||
>
|
||||
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
486
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
486
src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useForm, useWatch, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Drawer,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Link,
|
||||
Stack,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorFormFields } from "./vendor-form-fields";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const DRAWER_WIDTH = 420;
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text Message",
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "—";
|
||||
const first = parts[0]?.[0] ?? "";
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((trade) => trade.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||
return {
|
||||
companyName: vendor.companyName ?? "",
|
||||
contactName: vendor.contactName ?? "",
|
||||
email: vendor.email ?? "",
|
||||
phone: vendor.phone ?? "",
|
||||
companyPhone: vendor.companyPhone ?? "",
|
||||
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
||||
notes: vendor.notes ?? "",
|
||||
totalJobs: vendor.totalJobs ?? 0,
|
||||
address: vendor.address ?? "",
|
||||
city: vendor.city ?? "",
|
||||
state: vendor.state ?? "",
|
||||
zip: vendor.zip ?? "",
|
||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
interface VendorDetailDrawerProps {
|
||||
open: boolean;
|
||||
vendor: VendorListItem | null;
|
||||
mode: "view" | "edit";
|
||||
onClose: () => void;
|
||||
onModeChange: (mode: "view" | "edit") => void;
|
||||
tradeOptions: string[];
|
||||
}
|
||||
|
||||
export function VendorDetailDrawer({
|
||||
open,
|
||||
vendor,
|
||||
mode,
|
||||
onClose,
|
||||
onModeChange,
|
||||
tradeOptions,
|
||||
}: VendorDetailDrawerProps) {
|
||||
const vendorId = vendor?.id;
|
||||
const { data: facets } = useVendorFacets();
|
||||
const {
|
||||
data: detail,
|
||||
isLoading,
|
||||
error,
|
||||
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
||||
const updateVendor = useUpdateVendor();
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||
|
||||
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && detail) {
|
||||
const values = toFormValues(detail);
|
||||
reset(values);
|
||||
setOriginal(values);
|
||||
setSubmitError(null);
|
||||
}
|
||||
if (!open) {
|
||||
setOriginal(null);
|
||||
setSubmitError(null);
|
||||
}
|
||||
}, [open, detail, reset]);
|
||||
|
||||
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||
if (!company) return;
|
||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (original) {
|
||||
reset(original);
|
||||
}
|
||||
setSubmitError(null);
|
||||
onModeChange("view");
|
||||
};
|
||||
|
||||
const onSubmit = (values: VendorFormValues) => {
|
||||
if (vendorId === undefined || vendorId === null || vendorId === "") return;
|
||||
setSubmitError(null);
|
||||
updateVendor.mutate(
|
||||
{ id: vendorId, values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onModeChange("view");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSubmitError(err.message || "Failed to save vendor");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
||||
const mapsUrl = detail?.googleMapsUrl;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={updateVendor.isPending ? undefined : onClose}
|
||||
anchor="right"
|
||||
aria-labelledby="vendor-detail-drawer-title"
|
||||
ModalProps={{ keepMounted: false }}
|
||||
sx={{
|
||||
"& .MuiDrawer-paper": {
|
||||
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||
maxWidth: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack className="flex h-full flex-col">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box
|
||||
className="flex items-center justify-center"
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--accent, #1976d2)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{getInitials(vendor?.contactName ?? "")}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
id="vendor-detail-drawer-title"
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="font-semibold"
|
||||
>
|
||||
{vendor?.companyName || "Vendor"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
aria-label="Close drawer"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
disabled={updateVendor.isPending}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error instanceof Error ? error.message : "Failed to load vendor"}
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<>
|
||||
{mode === "view" ? (
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={detail.isActive ? "Active" : "Inactive"}
|
||||
color={detail.isActive ? "success" : "default"}
|
||||
/>
|
||||
{Boolean(trades[0]) && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<DetailSection title="Technician">
|
||||
<DetailField label="Technician Name" value={detail.contactName} />
|
||||
<DetailField label="Technician Phone" value={detail.phone} />
|
||||
<DetailField
|
||||
label="Preferred Contact"
|
||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Company">
|
||||
<DetailField label="Company" value={detail.companyName} />
|
||||
<DetailField label="Company Phone" value={detail.companyPhone} />
|
||||
<DetailField label="Email" value={detail.email} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Location">
|
||||
<DetailField
|
||||
label="Address"
|
||||
value={[detail.address, detail.city, detail.state, detail.zip]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
/>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Link
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<LaunchIcon fontSize="small" />
|
||||
Open in Google Maps
|
||||
</Link>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Trades">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades specified.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className="flex flex-wrap gap-1">
|
||||
{trades.map((trade, idx) => (
|
||||
<Chip
|
||||
key={`${trade}-${idx}`}
|
||||
size="small"
|
||||
label={idx === 0 ? `${trade} (primary)` : trade}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Activity">
|
||||
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||
</DetailSection>
|
||||
|
||||
{Boolean(detail.notes) && (
|
||||
<DetailSection title="Notes">
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{detail.notes}
|
||||
</Typography>
|
||||
</DetailSection>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box
|
||||
component="form"
|
||||
id="vendor-edit-form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-1 flex-col overflow-y-auto"
|
||||
sx={{ px: 3, py: 3 }}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
onChange={field.onChange}
|
||||
color="success"
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<VendorFormFields
|
||||
control={control}
|
||||
errors={errors}
|
||||
companies={companies}
|
||||
tradeOptions={tradeOptions}
|
||||
onCompanySelected={onCompanySelected}
|
||||
showTotalJobs
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
{mode === "view" ? (
|
||||
<>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Tooltip title="Open in Google Maps">
|
||||
<Button
|
||||
component="a"
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outlined"
|
||||
startIcon={<LaunchIcon />}
|
||||
>
|
||||
Maps
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box className="flex-1" />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => onModeChange("edit")}
|
||||
startIcon={<EditOutlinedIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleCancel}
|
||||
disabled={updateVendor.isPending}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-edit-form"
|
||||
disabled={updateVendor.isPending || !isFormValid}
|
||||
fullWidth
|
||||
>
|
||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No vendor selected.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-semibold uppercase"
|
||||
sx={{ color: "text.secondary", mb: 1 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
const display = value && value.trim() !== "" ? value : "—";
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{display}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
316
src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx
vendored
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type {
|
||||
VendorFacets,
|
||||
VendorFacetCompany,
|
||||
VendorFacetJobBucket,
|
||||
VendorFacetLocation,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||
|
||||
interface VendorFiltersDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
facets: VendorFacets | undefined;
|
||||
appliedFilters: VendorFilters;
|
||||
onApply: (filters: VendorFilters) => void;
|
||||
}
|
||||
|
||||
const DRAWER_WIDTH = 360;
|
||||
|
||||
function FilterSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||
{children}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorFiltersDrawer({
|
||||
open,
|
||||
onClose,
|
||||
facets,
|
||||
appliedFilters,
|
||||
onApply,
|
||||
}: VendorFiltersDrawerProps) {
|
||||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(appliedFilters);
|
||||
}
|
||||
}, [open, appliedFilters]);
|
||||
|
||||
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
||||
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
||||
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
||||
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
||||
|
||||
const activeCount =
|
||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||
|
||||
const handleClear = () => {
|
||||
setDraft(emptyVendorFilters);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(draft);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
anchor="right"
|
||||
aria-labelledby="vendor-filters-drawer-title"
|
||||
ModalProps={{ keepMounted: false }}
|
||||
sx={{
|
||||
"& .MuiDrawer-paper": {
|
||||
width: { xs: "100%", sm: DRAWER_WIDTH },
|
||||
maxWidth: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack className="flex h-full flex-col">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||
Filters
|
||||
</Typography>
|
||||
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
||||
</Stack>
|
||||
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Select vendor companies
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
companies:
|
||||
current.companies.length === companyOptions.length
|
||||
? []
|
||||
: companyOptions.map((company) => company.name),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||
? "Clear all"
|
||||
: "Select all"}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={companyOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.name,
|
||||
);
|
||||
setDraft((current) => ({ ...current, companies: next }));
|
||||
}}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...optionProps } = props;
|
||||
return (
|
||||
<li key={key} {...optionProps}>
|
||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||
{option.name}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.name;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={tradeOptions}
|
||||
value={draft.trades}
|
||||
onChange={(_event, value) =>
|
||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||
}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={locationOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.label === value.label
|
||||
}
|
||||
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.label,
|
||||
);
|
||||
setDraft((current) => ({ ...current, locations: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={jobBucketOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.id === value.id
|
||||
}
|
||||
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.id,
|
||||
);
|
||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
13
src/app/(protected)/vendors/_components/vendor-filters.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export interface VendorFilters {
|
||||
companies: string[];
|
||||
trades: string[];
|
||||
locations: string[];
|
||||
jobBuckets: string[];
|
||||
}
|
||||
|
||||
export const emptyVendorFilters: VendorFilters = {
|
||||
companies: [],
|
||||
trades: [],
|
||||
locations: [],
|
||||
jobBuckets: [],
|
||||
};
|
||||
420
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
420
src/app/(protected)/vendors/_components/vendor-form-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { useState } from "react";
|
||||
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
FormLabel,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||
type VendorFacetCompany,
|
||||
type VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
||||
interface VendorFormFieldsProps {
|
||||
control: Control<VendorFormValues>;
|
||||
errors: FieldErrors<VendorFormValues>;
|
||||
companies: VendorFacetCompany[];
|
||||
tradeOptions: string[];
|
||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
||||
showTotalJobs?: boolean;
|
||||
}
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((trade) => trade.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function joinTrades(trades: string[]): string {
|
||||
return trades.join(", ");
|
||||
}
|
||||
|
||||
function formatPhoneMask(input: string): string {
|
||||
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||
if (digits.length === 0) return "";
|
||||
if (digits.length < 4) return `(${digits}`;
|
||||
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text",
|
||||
};
|
||||
|
||||
export function VendorFormFields({
|
||||
control,
|
||||
errors,
|
||||
companies,
|
||||
tradeOptions,
|
||||
onCompanySelected,
|
||||
showTotalJobs = false,
|
||||
}: VendorFormFieldsProps) {
|
||||
const [tradeInput, setTradeInput] = useState("");
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Technician
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Name"
|
||||
required
|
||||
error={Boolean(errors.contactName)}
|
||||
helperText={errors.contactName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<FormLabel component="legend" sx={{ mb: 0.5 }}>
|
||||
Preferred Contact
|
||||
</FormLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="preferredContact"
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={field.value ?? "Phone"}
|
||||
onChange={(_event, value: VendorPreferredContact | null) => {
|
||||
if (value) field.onChange(value);
|
||||
}}
|
||||
aria-label="Preferred contact"
|
||||
>
|
||||
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
|
||||
<ToggleButton key={option} value={option} aria-label={option}>
|
||||
{PREFERRED_CONTACT_LABELS[option]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Company
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyId"
|
||||
render={({ field: companyIdField }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={companies}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
inputValue={field.value ?? ""}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
onChange={(_event, value, reason) => {
|
||||
if (typeof value === "string") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
field.onChange(value.name);
|
||||
companyIdField.onChange(value.companyId);
|
||||
if (reason === "selectOption") {
|
||||
onCompanySelected(value);
|
||||
}
|
||||
} else {
|
||||
field.onChange("");
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Select or type a company"
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Company Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.companyPhone)}
|
||||
helperText={errors.companyPhone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Email"
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="zip"
|
||||
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
control={control}
|
||||
name="googleMapsUrl"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL"
|
||||
placeholder="https://maps.google.com/..."
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
First trade is primary. Reorder with the arrows.
|
||||
</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tradeSpecialties"
|
||||
render={({ field }) => {
|
||||
const trades = splitTrades(field.value);
|
||||
const remove = (trade: string) => {
|
||||
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
|
||||
};
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...trades];
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
field.onChange(joinTrades(next));
|
||||
};
|
||||
const add = (trade: string) => {
|
||||
const normalized = trade.trim();
|
||||
if (!normalized || trades.includes(normalized)) {
|
||||
setTradeInput("");
|
||||
return;
|
||||
}
|
||||
field.onChange(joinTrades([...trades, normalized]));
|
||||
setTradeInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="mt-2">
|
||||
<Box className="flex flex-wrap gap-1.5">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades selected.
|
||||
</Typography>
|
||||
) : (
|
||||
trades.map((trade, index) => (
|
||||
<Chip
|
||||
key={`${trade}-${index}`}
|
||||
label={index === 0 ? `${trade} (primary)` : trade}
|
||||
onDelete={() => remove(trade)}
|
||||
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||
sx={{ px: 0.5 }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={tradeOptions}
|
||||
value={null}
|
||||
inputValue={tradeInput}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") setTradeInput(value);
|
||||
}}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value === "string") add(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Add Trade"
|
||||
placeholder="Type or select a trade"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
add(tradeInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => add(tradeInput)}
|
||||
aria-label="Add trade"
|
||||
disabled={!tradeInput.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Stack>
|
||||
{trades.length > 1 && (
|
||||
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
|
||||
{trades.map((trade, index) => (
|
||||
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} up`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} down`}
|
||||
disabled={index === trades.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{showTotalJobs && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="totalJobs"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? 0}
|
||||
label="Total Jobs"
|
||||
type="number"
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
helperText="Calculated from work-order history"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="Notes" multiline minRows={3} fullWidth />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,34 +6,40 @@ import {
|
|||
Alert,
|
||||
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>
|
||||
|
|
|
|||
598
src/app/(protected)/vendors/index.tsx
vendored
598
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,9 +1,11 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { 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 "{deleteTarget?.companyName}"?
|
||||
</DialogContentText>
|
||||
<Stack spacing={2}>
|
||||
<DialogContentText>
|
||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||
history will be preserved.
|
||||
</DialogContentText>
|
||||
|
||||
{isLoadingImpact && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Checking linked work orders...
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{Boolean(impactError) && !isLoadingImpact && (
|
||||
<Alert severity="warning">
|
||||
Could not verify linked work orders. Close and try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deactivationImpact != null &&
|
||||
!isLoadingImpact &&
|
||||
!deactivationImpact.canDeactivate && (
|
||||
<Alert severity="error">
|
||||
This vendor cannot be deactivated because it still has open work orders.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Open work orders ({deactivationImpact.openWorkOrders.length})
|
||||
</Typography>
|
||||
<List dense sx={{ mt: 0.5 }}>
|
||||
{deactivationImpact.openWorkOrders.map((wo) => (
|
||||
<ListItem
|
||||
key={String(wo.workOrderId)}
|
||||
sx={{ px: 0, py: 0.25 }}
|
||||
secondaryAction={
|
||||
wo.scheduledDate ? (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{wo.scheduledDate}
|
||||
</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<Typography variant="body2">
|
||||
{wo.workOrderNumber
|
||||
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||
</Typography>
|
||||
{Boolean(wo.status) && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{wo.status}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
||||
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteVendor.isPending}
|
||||
disabled={
|
||||
deleteVendor.isPending ||
|
||||
isLoadingImpact ||
|
||||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
||||
Boolean(impactError)
|
||||
}
|
||||
>
|
||||
{deleteVendor.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
|
||||
function percent(value: number) {
|
||||
return `${(Number(value || 0) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function VendorInsightsPage() {
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [trade, setTrade] = useState("");
|
||||
const params = { from: from || undefined, to: to || undefined, trade: trade || undefined };
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["vendor-operations", "insights", params],
|
||||
queryFn: () => vendorOperationsApi.insights(params),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendor performance insights
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Metrics include inactive vendors for historical reporting and use the same filtered
|
||||
dispatch population.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(event) => setFrom(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(event) => setTo(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Trade"
|
||||
value={trade}
|
||||
onChange={(event) => setTrade(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void vendorOperationsApi.downloadInsights("csv", params)}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void vendorOperationsApi.downloadInsights("pdf", params)}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{isLoading && <CircularProgress aria-label="Loading vendor insights" />}
|
||||
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Alert severity="info">No data matches the selected filters.</Alert>
|
||||
)}
|
||||
{data.length > 0 && (
|
||||
<Paper variant="outlined" sx={{ overflowX: "auto" }}>
|
||||
<Table aria-label="Vendor performance metrics">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell align="right">Completed</TableCell>
|
||||
<TableCell align="right">Cancellation / refusal</TableCell>
|
||||
<TableCell align="right">On-time arrival</TableCell>
|
||||
<TableCell align="right">On-time completion</TableCell>
|
||||
<TableCell align="right">Avg. cycle</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row) => (
|
||||
<TableRow key={row.vendorId}>
|
||||
<TableCell>{row.vendor}</TableCell>
|
||||
<TableCell align="right">{row.completedJobs}</TableCell>
|
||||
<TableCell align="right">{percent(row.cancellationRefusalRate)}</TableCell>
|
||||
<TableCell align="right">{percent(row.onTimeArrivalRate)}</TableCell>
|
||||
<TableCell align="right">{percent(row.onTimeCompletionRate)}</TableCell>
|
||||
<TableCell align="right">
|
||||
{row.averageCycleHours == null
|
||||
? "Insufficient data"
|
||||
: `${Number(row.averageCycleHours).toFixed(1)}h`}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
254
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
|
||||
|
||||
type AddVendorPreferenceControlsProps = {
|
||||
trade: string;
|
||||
onTradeChange: (value: string) => void;
|
||||
vendorOptions: VendorDropdownItem[];
|
||||
vendors: VendorDropdownItem[];
|
||||
vendorId: number | null;
|
||||
onVendorChange: (vendorId: number | null) => void;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
function AddVendorPreferenceControls({
|
||||
trade,
|
||||
onTradeChange,
|
||||
vendorOptions,
|
||||
vendors,
|
||||
vendorId,
|
||||
onVendorChange,
|
||||
onAdd,
|
||||
}: AddVendorPreferenceControlsProps) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Trade scope (optional)"
|
||||
value={trade}
|
||||
onChange={(event) => onTradeChange(event.target.value)}
|
||||
/>
|
||||
<Autocomplete
|
||||
options={vendorOptions}
|
||||
getOptionLabel={(option) =>
|
||||
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
|
||||
}
|
||||
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
|
||||
onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)}
|
||||
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="contained" onClick={onAdd} disabled={vendorId == null}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
type PreferenceRowProps = {
|
||||
item: SiteVendorPreference;
|
||||
index: number;
|
||||
total: number;
|
||||
onMove: (index: number, offset: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-3">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Text className="flex-1">
|
||||
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
|
||||
</Text>
|
||||
{item.isStale && (
|
||||
<Alert severity="warning">
|
||||
<Text as="span">Stale preference</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label={`Move ${item.vendorName} up`}
|
||||
onClick={() => onMove(index, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={`Move ${item.vendorName} down`}
|
||||
onClick={() => onMove(index, 1)}
|
||||
disabled={index === total - 1}
|
||||
>
|
||||
<ArrowDown size={18} />
|
||||
</IconButton>
|
||||
<IconButton aria-label={`Remove ${item.vendorName}`} onClick={() => onRemove(index)}>
|
||||
<Trash2 size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VendorPreferencesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [locationId, setLocationId] = useState<string | number>("");
|
||||
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [trade, setTrade] = useState("");
|
||||
const [vendorId, setVendorId] = useState<number | null>(null);
|
||||
const { data: locations = [] } = useWorkOrderLocations();
|
||||
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
|
||||
const queryKey = ["vendor-operations", "site-preferences", locationId];
|
||||
const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
|
||||
enabled: locationId !== "",
|
||||
});
|
||||
|
||||
// A background refetch must not clobber in-flight edits, so only adopt server
|
||||
// data while the draft is clean (initial load, site change, or after a save).
|
||||
useEffect(() => {
|
||||
if (!isDirty) {
|
||||
setDraft(serverPreferences);
|
||||
}
|
||||
}, [isDirty, serverPreferences]);
|
||||
|
||||
const vendorOptions = useMemo(() => {
|
||||
const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId));
|
||||
return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id)));
|
||||
}, [draft, vendors]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
vendorOperationsApi.replaceSitePreferences(
|
||||
locationId,
|
||||
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
|
||||
),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
},
|
||||
onSuccess: (savedPreferences) => {
|
||||
queryClient.setQueryData(queryKey, savedPreferences);
|
||||
setDraft(savedPreferences);
|
||||
setIsDirty(false);
|
||||
toast.success("Preferred vendors updated");
|
||||
},
|
||||
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
|
||||
});
|
||||
|
||||
const move = (index: number, offset: number) => {
|
||||
setDraft((current) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
|
||||
});
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
const vendor = vendors.find((item) => Number(item.id) === vendorId);
|
||||
if (!vendor || locationId === "") return;
|
||||
setDraft((current) => [
|
||||
...current,
|
||||
{
|
||||
id: -Date.now(),
|
||||
locationId: Number(locationId),
|
||||
vendorId: Number(vendor.id),
|
||||
vendorName: vendor.companyName,
|
||||
trade: trade.trim() || undefined,
|
||||
sortOrder: current.length,
|
||||
isStale: false,
|
||||
},
|
||||
]);
|
||||
setVendorId(null);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
setDraft((current) => current.filter((_, row) => row !== index));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const selectLocation = (nextLocationId: string | number) => {
|
||||
setLocationId(nextLocationId);
|
||||
setDraft(EMPTY_PREFERENCES);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box>
|
||||
<Text variant="title" as="h1">
|
||||
Preferred vendors by site
|
||||
</Text>
|
||||
<Text variant="description" tone="muted">
|
||||
Preferred vendors rank first, but availability, conflict, trade, active-state, and
|
||||
permission checks still apply.
|
||||
</Text>
|
||||
</Box>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Autocomplete
|
||||
options={locations}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={locations.find((location) => location.id === locationId) ?? null}
|
||||
onChange={(_event, option) => selectLocation(option?.id ?? "")}
|
||||
renderInput={(params) => <TextField {...params} label="Site" />}
|
||||
/>
|
||||
</Paper>
|
||||
<Text variant="error" when={Boolean(error)}>
|
||||
Unable to load preferred vendors. Please try again.
|
||||
</Text>
|
||||
{locationId !== "" && (
|
||||
<>
|
||||
<AddVendorPreferenceControls
|
||||
trade={trade}
|
||||
onTradeChange={setTrade}
|
||||
vendorOptions={vendorOptions}
|
||||
vendors={vendors}
|
||||
vendorId={vendorId}
|
||||
onVendorChange={setVendorId}
|
||||
onAdd={add}
|
||||
/>
|
||||
<Stack spacing={1}>
|
||||
{draft.map((item, index) => (
|
||||
<PreferenceRow
|
||||
key={`${item.vendorId}-${item.trade ?? "all"}`}
|
||||
item={item}
|
||||
index={index}
|
||||
total={draft.length}
|
||||
onMove={move}
|
||||
onRemove={remove}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
{save.isPending ? "Saving…" : "Save preferences"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function DispatcherFilter({
|
|||
onClick={() => onChange(new Set())}
|
||||
/>
|
||||
|
||||
{meId && (
|
||||
{meId != null && (
|
||||
<CheckboxMenuItem
|
||||
checked={meOnly}
|
||||
label="My WOs only"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function WoTableSectionHeader({
|
|||
>
|
||||
{count} WO{count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{isToday && (
|
||||
{Boolean(isToday) && (
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorAvailability } from "@/domain/vendor-operations/types/vendor-operations";
|
||||
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type VendorAndTimeSectionProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
availableVendors: VendorAvailability[];
|
||||
isLoadingVendors: boolean;
|
||||
selectedVendorId: number | null;
|
||||
onSelectVendor: (id: number | null) => void;
|
||||
estimatedArrivalAt: string;
|
||||
onEtaChange: (value: string) => void;
|
||||
etaManualOverride: boolean;
|
||||
onOverrideChange: (value: boolean) => void;
|
||||
scheduledStartLocal: string;
|
||||
onScheduledStartChange: (value: string) => void;
|
||||
scheduledEndLocal: string;
|
||||
onScheduledEndChange: (value: string) => void;
|
||||
scheduleBoundsError: string | null;
|
||||
};
|
||||
|
||||
export function VendorAndTimeSection({
|
||||
containerRef,
|
||||
availableVendors,
|
||||
isLoadingVendors,
|
||||
selectedVendorId,
|
||||
onSelectVendor,
|
||||
estimatedArrivalAt,
|
||||
onEtaChange,
|
||||
etaManualOverride,
|
||||
onOverrideChange,
|
||||
scheduledStartLocal,
|
||||
onScheduledStartChange,
|
||||
scheduledEndLocal,
|
||||
onScheduledEndChange,
|
||||
scheduleBoundsError,
|
||||
}: VendorAndTimeSectionProps) {
|
||||
const hasBoundsError = Boolean(scheduleBoundsError);
|
||||
return (
|
||||
<Box ref={containerRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
|
||||
<SectionTitle>Vendor & Time</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Appointment start"
|
||||
type="datetime-local"
|
||||
value={scheduledStartLocal}
|
||||
onChange={(event) => onScheduledStartChange(event.target.value)}
|
||||
error={hasBoundsError}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label="Appointment end"
|
||||
type="datetime-local"
|
||||
value={scheduledEndLocal}
|
||||
onChange={(event) => onScheduledEndChange(event.target.value)}
|
||||
error={hasBoundsError}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
</Stack>
|
||||
<Text variant="error" when={hasBoundsError} className="mt-1">
|
||||
{scheduleBoundsError}
|
||||
</Text>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-2">
|
||||
<Autocomplete
|
||||
options={availableVendors}
|
||||
loading={isLoadingVendors}
|
||||
getOptionLabel={(option) =>
|
||||
`${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}`
|
||||
}
|
||||
value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null}
|
||||
onChange={(_event, option) => onSelectVendor(option?.id ?? null)}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
<Stack className="w-full">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Text as="span">
|
||||
{option.companyName}
|
||||
{option.contactName ? ` · ${option.contactName}` : ""}
|
||||
</Text>
|
||||
{option.isPreferred && <Chip label="Preferred" color="primary" size="small" />}
|
||||
<Chip
|
||||
label={option.availabilityStatus}
|
||||
color={
|
||||
option.availabilityStatus === "Available"
|
||||
? "success"
|
||||
: option.availabilityStatus === "Unavailable"
|
||||
? "error"
|
||||
: "default"
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
{option.conflicts.length > 0 && (
|
||||
<Text variant="caption" tone="error">
|
||||
Conflicts with{" "}
|
||||
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label="Assignment ETA"
|
||||
type="datetime-local"
|
||||
value={estimatedArrivalAt}
|
||||
onChange={(event) => onEtaChange(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={etaManualOverride}
|
||||
onChange={(event) => onOverrideChange(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Manual ETA override"
|
||||
/>
|
||||
</Stack>
|
||||
{selectedVendorId != null &&
|
||||
availableVendors.find((vendor) => vendor.id === selectedVendorId)?.availabilityStatus ===
|
||||
"Unknown" && (
|
||||
<Alert severity="warning" className="mt-2">
|
||||
<Text as="span">
|
||||
Availability is unknown. This selection is not treated as confirmed availability.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_OPTIONS,
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type LocationOption,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common";
|
||||
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type FormFieldHandlers = {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
control: Control<WorkOrderFormSchemaValues>;
|
||||
errors: FieldErrors<WorkOrderFormSchemaValues>;
|
||||
};
|
||||
|
||||
type WorkOrderDetailsFieldsProps = FormFieldHandlers & {
|
||||
locationOptions: LocationOption[];
|
||||
isLoadingLocations: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrderDetailsFields({
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
locationOptions,
|
||||
isLoadingLocations,
|
||||
}: WorkOrderDetailsFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Work Order Details</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("workOrderNumber")}
|
||||
label="Customer WO"
|
||||
placeholder="Customer work order number"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("title")}
|
||||
label="Title"
|
||||
required
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
||||
<Controller
|
||||
name="locationId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={locationOptions}
|
||||
loading={isLoadingLocations}
|
||||
getOptionLabel={(option: LocationOption) => option.name}
|
||||
value={
|
||||
locationOptions.find((location) => String(location.id) === field.value) ?? null
|
||||
}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Location"
|
||||
required
|
||||
error={Boolean(errors.locationId)}
|
||||
helperText={errors.locationId?.message}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("source")}
|
||||
label="Source"
|
||||
placeholder="e.g., Amazon APM, Manual"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ClassificationFieldsProps = {
|
||||
control: Control<WorkOrderFormSchemaValues>;
|
||||
problems: DropdownOption[];
|
||||
trades: DropdownOption[];
|
||||
subTrades: DropdownOption[];
|
||||
tradeValue: string;
|
||||
};
|
||||
|
||||
export function ClassificationFields({
|
||||
control,
|
||||
problems,
|
||||
trades,
|
||||
subTrades,
|
||||
tradeValue,
|
||||
}: ClassificationFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Classification</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<Controller
|
||||
name="problem"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} value={field.value ?? ""} label="Problem" select fullWidth>
|
||||
<MenuItem value="">Select Problem</MenuItem>
|
||||
{problems.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="trade"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} value={field.value ?? ""} label="Trade" select fullWidth>
|
||||
<MenuItem value="">Select Trade</MenuItem>
|
||||
{trades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="subTrade"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
label="Sub-Trade"
|
||||
select
|
||||
disabled={!tradeValue}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
||||
</MenuItem>
|
||||
{subTrades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleFieldsProps = FormFieldHandlers & {
|
||||
employees: EmployeeOption[];
|
||||
};
|
||||
|
||||
export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Schedule & Priority</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<Controller
|
||||
name="priority"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
label="Priority"
|
||||
select
|
||||
required
|
||||
error={Boolean(errors.priority)}
|
||||
helperText={errors.priority?.message}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">Select Priority</MenuItem>
|
||||
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
||||
<MenuItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? "Open"}
|
||||
label="Status"
|
||||
select
|
||||
required
|
||||
fullWidth
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="assignedTo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={employees.map((employee) => employee.name)}
|
||||
value={field.value}
|
||||
onChange={(_event, value) => field.onChange(value ?? "")}
|
||||
onInputChange={(_event, value) => field.onChange(value)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
||||
<TextField
|
||||
{...register("createdDate")}
|
||||
label="Created"
|
||||
type="date"
|
||||
required
|
||||
error={Boolean(errors.createdDate)}
|
||||
helperText={errors.createdDate?.message}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("dueDate")}
|
||||
label="Due Date"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("scheduledDate")}
|
||||
label="Scheduled"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("completedDate")}
|
||||
label="Completed"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinancialFields({
|
||||
register,
|
||||
}: {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Financial</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { step: "0.01", min: 0 } }}
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField {...register("tt")} label="TT" placeholder="Trouble ticket number" fullWidth />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DescriptionFields({
|
||||
register,
|
||||
}: {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Description</SectionTitle>
|
||||
<TextField
|
||||
{...register("description")}
|
||||
label="Description"
|
||||
multiline
|
||||
rows={8}
|
||||
placeholder="Detailed description of the work to be performed"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +1,141 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Link,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import {
|
||||
workOrderFormSchema,
|
||||
type WorkOrderFormSchemaValues,
|
||||
} from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import {
|
||||
createDefaultWorkOrderFormValues,
|
||||
WORK_ORDER_PRIORITY_OPTIONS,
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type LocationOption,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
useWorkOrderDetail,
|
||||
useWorkOrderEmployeesDropdown,
|
||||
useWorkOrderLocations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order";
|
||||
import { toDateTimeLocalInputValue } from "@/lib/time-utils";
|
||||
import {
|
||||
useCreateWorkOrder,
|
||||
useUpdateWorkOrder,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
ClassificationFields,
|
||||
DescriptionFields,
|
||||
FinancialFields,
|
||||
ScheduleFields,
|
||||
WorkOrderDetailsFields,
|
||||
} from "@/app/(protected)/workorders/_components/work-order-fields";
|
||||
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
|
||||
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Typography variant="subtitle2" className="mb-3 font-semibold uppercase tracking-wide">
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
type ScheduleBounds = {
|
||||
kind: "valid" | "invalid" | "open";
|
||||
startIso: string | undefined;
|
||||
endIso: string | undefined;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds {
|
||||
const hasStart = startLocal.trim() !== "";
|
||||
const hasEnd = endLocal.trim() !== "";
|
||||
|
||||
if (hasStart && hasEnd) {
|
||||
const start = new Date(startLocal);
|
||||
const end = new Date(endLocal);
|
||||
if (
|
||||
!Number.isNaN(start.getTime()) &&
|
||||
!Number.isNaN(end.getTime()) &&
|
||||
end.getTime() > start.getTime()
|
||||
) {
|
||||
return {
|
||||
kind: "valid",
|
||||
startIso: start.toISOString(),
|
||||
endIso: end.toISOString(),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "invalid",
|
||||
startIso: undefined,
|
||||
endIso: undefined,
|
||||
error: "Schedule end must be later than schedule start.",
|
||||
};
|
||||
}
|
||||
|
||||
if (hasStart || hasEnd) {
|
||||
return {
|
||||
kind: "invalid",
|
||||
startIso: undefined,
|
||||
endIso: undefined,
|
||||
error: "Provide both a schedule start and end, or leave both blank.",
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "open", startIso: undefined, endIso: undefined, error: null };
|
||||
}
|
||||
|
||||
export default function WorkOrderFormPage() {
|
||||
function useVendorSchedule(
|
||||
scheduledStartLocal: string,
|
||||
scheduledEndLocal: string,
|
||||
locationValue: string,
|
||||
tradeValue: string,
|
||||
) {
|
||||
const scheduleBounds = useMemo(
|
||||
() => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal),
|
||||
[scheduledStartLocal, scheduledEndLocal],
|
||||
);
|
||||
|
||||
const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({
|
||||
queryKey: [
|
||||
"vendor-operations",
|
||||
"availability",
|
||||
scheduleBounds.startIso ?? null,
|
||||
scheduleBounds.endIso ?? null,
|
||||
locationValue,
|
||||
tradeValue,
|
||||
],
|
||||
queryFn: () =>
|
||||
vendorOperationsApi.availability({
|
||||
start: scheduleBounds.startIso,
|
||||
end: scheduleBounds.endIso,
|
||||
locationId: locationValue || undefined,
|
||||
trade: tradeValue || undefined,
|
||||
}),
|
||||
enabled: scheduleBounds.kind !== "invalid",
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const truthfulVendors =
|
||||
scheduleBounds.kind === "valid"
|
||||
? availableVendors
|
||||
: availableVendors.map((vendor) => ({
|
||||
...vendor,
|
||||
availabilityStatus: "Unknown" as const,
|
||||
conflicts: [],
|
||||
}));
|
||||
|
||||
return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds };
|
||||
}
|
||||
|
||||
function useWorkOrderFormState() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isEdit = Boolean(id);
|
||||
const assignVendorRequested = searchParams.get("assignVendor") === "1";
|
||||
|
||||
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
|
||||
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
||||
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const createMutation = useCreateWorkOrder();
|
||||
const updateMutation = useUpdateWorkOrder(id ?? "");
|
||||
const saveMutation = useSaveWorkOrder();
|
||||
const vendorSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null);
|
||||
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
|
||||
const [etaManualOverride, setEtaManualOverride] = useState(false);
|
||||
const [scheduledStartLocal, setScheduledStartLocal] = useState("");
|
||||
const [scheduledEndLocal, setScheduledEndLocal] = useState("");
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -71,6 +150,14 @@ export default function WorkOrderFormPage() {
|
|||
});
|
||||
|
||||
const tradeValue = useWatch({ control, name: "trade" });
|
||||
const locationValue = useWatch({ control, name: "locationId" });
|
||||
|
||||
const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule(
|
||||
scheduledStartLocal,
|
||||
scheduledEndLocal,
|
||||
locationValue,
|
||||
tradeValue,
|
||||
);
|
||||
|
||||
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
|
||||
parent: tradeValue || undefined,
|
||||
|
|
@ -81,6 +168,10 @@ export default function WorkOrderFormPage() {
|
|||
useEffect(() => {
|
||||
if (isEdit && detail) {
|
||||
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
||||
const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled");
|
||||
setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null);
|
||||
setEstimatedArrivalAt(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt));
|
||||
setEtaManualOverride(Boolean(dispatch?.etaManualOverride));
|
||||
}
|
||||
}, [detail, isEdit, reset]);
|
||||
|
||||
|
|
@ -90,23 +181,120 @@ export default function WorkOrderFormPage() {
|
|||
}
|
||||
}, [tradeValue, setValue]);
|
||||
|
||||
const locationOptions = useMemo(() => locations, [locations]);
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const isLoading = isEdit && isLoadingDetail;
|
||||
|
||||
useEffect(() => {
|
||||
if (assignVendorRequested && !isLoading) {
|
||||
const node = vendorSectionRef.current;
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
node.focus();
|
||||
}
|
||||
}
|
||||
}, [assignVendorRequested, isLoading]);
|
||||
|
||||
const onSubmit = (values: WorkOrderFormSchemaValues) => {
|
||||
if (isEdit && id) {
|
||||
updateMutation.mutate(values);
|
||||
if (scheduleBounds.kind === "invalid") {
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(values);
|
||||
saveMutation.mutate({
|
||||
workOrderId: isEdit ? id : undefined,
|
||||
values,
|
||||
vendorAssignment:
|
||||
selectedVendorId == null
|
||||
? undefined
|
||||
: {
|
||||
vendorId: selectedVendorId,
|
||||
scheduledStart: scheduleBounds.startIso,
|
||||
scheduledEnd: scheduleBounds.endIso,
|
||||
estimatedArrivalAt: estimatedArrivalAt
|
||||
? new Date(estimatedArrivalAt).toISOString()
|
||||
: undefined,
|
||||
etaManualOverride,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
isEdit,
|
||||
detail,
|
||||
isLoading,
|
||||
detailError,
|
||||
isSaving: saveMutation.isPending,
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
errors,
|
||||
locationOptions: locations,
|
||||
isLoadingLocations,
|
||||
employees,
|
||||
problems,
|
||||
trades,
|
||||
subTrades,
|
||||
tradeValue,
|
||||
vendorSectionRef,
|
||||
availableVendors,
|
||||
isLoadingVendors,
|
||||
selectedVendorId,
|
||||
setSelectedVendorId,
|
||||
estimatedArrivalAt,
|
||||
setEstimatedArrivalAt,
|
||||
etaManualOverride,
|
||||
setEtaManualOverride,
|
||||
scheduledStartLocal,
|
||||
setScheduledStartLocal,
|
||||
scheduledEndLocal,
|
||||
setScheduledEndLocal,
|
||||
scheduleBoundsError: scheduleBounds.error,
|
||||
isScheduleBoundsInvalid: scheduleBounds.kind === "invalid",
|
||||
onSubmit,
|
||||
handleCancel,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WorkOrderFormPage() {
|
||||
const {
|
||||
isEdit,
|
||||
id,
|
||||
detail,
|
||||
isLoading,
|
||||
detailError,
|
||||
isSaving,
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
errors,
|
||||
locationOptions,
|
||||
isLoadingLocations,
|
||||
employees,
|
||||
problems,
|
||||
trades,
|
||||
subTrades,
|
||||
tradeValue,
|
||||
vendorSectionRef,
|
||||
availableVendors,
|
||||
isLoadingVendors,
|
||||
selectedVendorId,
|
||||
setSelectedVendorId,
|
||||
estimatedArrivalAt,
|
||||
setEstimatedArrivalAt,
|
||||
etaManualOverride,
|
||||
setEtaManualOverride,
|
||||
scheduledStartLocal,
|
||||
setScheduledStartLocal,
|
||||
scheduledEndLocal,
|
||||
setScheduledEndLocal,
|
||||
scheduleBoundsError,
|
||||
isScheduleBoundsInvalid,
|
||||
onSubmit,
|
||||
handleCancel,
|
||||
} = useWorkOrderFormState();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-12">
|
||||
|
|
@ -118,9 +306,7 @@ export default function WorkOrderFormPage() {
|
|||
if (detailError) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
|
||||
</Alert>
|
||||
<Text variant="error">Unable to load this work order. Please try again.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -131,232 +317,66 @@ export default function WorkOrderFormPage() {
|
|||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{isEdit ? "Edit" : "Create"}
|
||||
</Typography>
|
||||
<Text as="span">{isEdit ? "Edit" : "Create"}</Text>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h5" component="h1">
|
||||
<Text variant="title" as="h1">
|
||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||
</Typography>
|
||||
</Text>
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||
<Box>
|
||||
<SectionTitle>Work Order Details</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("workOrderNumber")}
|
||||
label="Customer WO"
|
||||
placeholder="Customer work order number"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("title")}
|
||||
label="Title"
|
||||
required
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
||||
<Controller
|
||||
name="locationId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={locationOptions}
|
||||
loading={isLoadingLocations}
|
||||
getOptionLabel={(option: LocationOption) => option.name}
|
||||
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Location"
|
||||
required
|
||||
error={Boolean(errors.locationId)}
|
||||
helperText={errors.locationId?.message}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("source")}
|
||||
label="Source"
|
||||
placeholder="e.g., Amazon APM, Manual"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<WorkOrderDetailsFields
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
locationOptions={locationOptions}
|
||||
isLoadingLocations={isLoadingLocations}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Classification</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("problem")} label="Problem" select fullWidth>
|
||||
<MenuItem value="">Select Problem</MenuItem>
|
||||
{problems.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField {...register("trade")} label="Trade" select fullWidth>
|
||||
<MenuItem value="">Select Trade</MenuItem>
|
||||
{trades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
{...register("subTrade")}
|
||||
label="Sub-Trade"
|
||||
select
|
||||
disabled={!tradeValue}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
||||
</MenuItem>
|
||||
{subTrades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
</Box>
|
||||
<VendorAndTimeSection
|
||||
containerRef={vendorSectionRef}
|
||||
availableVendors={availableVendors}
|
||||
isLoadingVendors={isLoadingVendors}
|
||||
selectedVendorId={selectedVendorId}
|
||||
onSelectVendor={setSelectedVendorId}
|
||||
estimatedArrivalAt={estimatedArrivalAt}
|
||||
onEtaChange={setEstimatedArrivalAt}
|
||||
etaManualOverride={etaManualOverride}
|
||||
onOverrideChange={setEtaManualOverride}
|
||||
scheduledStartLocal={scheduledStartLocal}
|
||||
onScheduledStartChange={setScheduledStartLocal}
|
||||
scheduledEndLocal={scheduledEndLocal}
|
||||
onScheduledEndChange={setScheduledEndLocal}
|
||||
scheduleBoundsError={scheduleBoundsError}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Schedule & Priority</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("priority")}
|
||||
label="Priority"
|
||||
select
|
||||
required
|
||||
error={Boolean(errors.priority)}
|
||||
helperText={errors.priority?.message}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">Select Priority</MenuItem>
|
||||
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
||||
<MenuItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField {...register("status")} label="Status" select required fullWidth>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Controller
|
||||
name="assignedTo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={employees.map((employee) => employee.name)}
|
||||
value={field.value}
|
||||
onChange={(_event, value) => field.onChange(value ?? "")}
|
||||
onInputChange={(_event, value) => field.onChange(value)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
||||
<TextField
|
||||
{...register("createdDate")}
|
||||
label="Created"
|
||||
type="date"
|
||||
required
|
||||
error={Boolean(errors.createdDate)}
|
||||
helperText={errors.createdDate?.message}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("dueDate")}
|
||||
label="Due Date"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("scheduledDate")}
|
||||
label="Scheduled"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("completedDate")}
|
||||
label="Completed"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<ClassificationFields
|
||||
control={control}
|
||||
problems={problems}
|
||||
trades={trades}
|
||||
subTrades={subTrades}
|
||||
tradeValue={tradeValue}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Financial</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01", min: 0 },
|
||||
}}
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField
|
||||
{...register("tt")}
|
||||
label="TT"
|
||||
placeholder="Trouble ticket number"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<ScheduleFields
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
employees={employees}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Description</SectionTitle>
|
||||
<TextField
|
||||
{...register("description")}
|
||||
label="Description"
|
||||
multiline
|
||||
rows={8}
|
||||
placeholder="Detailed description of the work to be performed"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<FinancialFields register={register} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<DescriptionFields register={register} />
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
|
||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSaving || isScheduleBoundsInvalid}
|
||||
>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export default function WorkOrdersListPage() {
|
|||
scopeTotalCount={scopeTotalCount}
|
||||
/>
|
||||
|
||||
{filters.advApplied && (
|
||||
{filters.advApplied != null && (
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={filters.advApplied}
|
||||
totalCount={totalCount}
|
||||
|
|
@ -131,7 +131,7 @@ export default function WorkOrdersListPage() {
|
|||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
{activeMediaRow && (
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,404 +1,21 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useParams } from "react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
|
||||
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
vendorPortalStatusClass,
|
||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type {
|
||||
VendorPortalChecklistItem,
|
||||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
VendorPortalSignoff,
|
||||
VendorPortalUpliftRequest,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function ChecklistSection({
|
||||
token,
|
||||
dispatchId,
|
||||
items,
|
||||
locked,
|
||||
onItemUpdated,
|
||||
}: {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
items: VendorPortalChecklistItem[];
|
||||
locked: boolean;
|
||||
onItemUpdated: (item: VendorPortalChecklistItem) => void;
|
||||
}) {
|
||||
const toggle = async (item: VendorPortalChecklistItem) => {
|
||||
if (locked) return;
|
||||
try {
|
||||
const updated = await vendorPortalApi.updateChecklistItem(
|
||||
token,
|
||||
dispatchId,
|
||||
item.id,
|
||||
!item.isCompleted,
|
||||
);
|
||||
onItemUpdated(updated);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Failed to update checklist");
|
||||
}
|
||||
};
|
||||
|
||||
if (!items.length) {
|
||||
return <div className="vp-muted">No checklist items for this dispatch.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`cl-${item.id}`}
|
||||
checked={item.isCompleted}
|
||||
onChange={() => void toggle(item)}
|
||||
disabled={locked}
|
||||
/>
|
||||
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
||||
{item.itemText}
|
||||
</label>
|
||||
{item.isCompleted && item.completedAt && (
|
||||
<span className="vp-muted" style={{ fontSize: 12 }}>
|
||||
{formatVendorPortalDateTime(item.completedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsSection({
|
||||
token,
|
||||
dispatchId,
|
||||
comments,
|
||||
onAdded,
|
||||
}: {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
comments: VendorPortalComment[];
|
||||
onAdded: (comment: VendorPortalComment) => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
||||
onAdded(added);
|
||||
setText("");
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Failed to post comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.length === 0 && <div className="vp-muted">No comments yet.</div>}
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
|
||||
<div className="vp-comment-meta">
|
||||
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
|
||||
</div>
|
||||
<div>{comment.commenttext}</div>
|
||||
</div>
|
||||
))}
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<textarea
|
||||
className="vp-textarea"
|
||||
placeholder="Add a comment for the dispatcher…"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{submitting ? "Posting…" : "Post Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftRequestsSection({
|
||||
token,
|
||||
dispatchId,
|
||||
currentNte,
|
||||
upliftRequests,
|
||||
locked,
|
||||
onChanged,
|
||||
}: {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
currentNte?: number;
|
||||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const [requested, setRequested] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const pending = upliftRequests.find((request) => request.status === "Pending");
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const amount = Number.parseFloat(requested);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
alert("Enter a valid requested NTE amount.");
|
||||
return;
|
||||
}
|
||||
if (currentNte != null && amount <= Number(currentNte)) {
|
||||
alert(
|
||||
`Requested NTE must be greater than the current NTE ($${Number(currentNte).toFixed(2)}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
alert("Please include a reason for the uplift request.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.requestUplift(token, dispatchId, {
|
||||
requestedNTE: amount,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
setRequested("");
|
||||
setReason("");
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Failed to request uplift");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async (requestId: number) => {
|
||||
if (!window.confirm("Cancel this uplift request?")) return;
|
||||
try {
|
||||
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Failed to cancel uplift");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{upliftRequests.length === 0 && !pending && (
|
||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upliftRequests.map((request) => (
|
||||
<div key={request.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>Requested</strong>
|
||||
<span>
|
||||
${Number(request.requestedNTE).toFixed(2)} (was $
|
||||
{Number(request.currentNTE ?? 0).toFixed(2)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Status</strong>
|
||||
<span>
|
||||
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{request.vendorReason && (
|
||||
<div className="vp-row">
|
||||
<strong>Reason</strong>
|
||||
<span>{request.vendorReason}</span>
|
||||
</div>
|
||||
)}
|
||||
{request.status === "Pending" && (
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
onClick={() => void cancel(request.id)}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!locked && !pending && (
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<div className="vp-row">
|
||||
<strong>Current NTE</strong>
|
||||
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>New total NTE</strong>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="vp-input"
|
||||
value={requested}
|
||||
onChange={(event) => setRequested(event.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Reason</strong>
|
||||
<textarea
|
||||
className="vp-textarea"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="Why is the uplift needed?"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Request Uplift"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignoffSection({
|
||||
token,
|
||||
dispatchId,
|
||||
existing,
|
||||
signoffType,
|
||||
nameLabel,
|
||||
onAdded,
|
||||
}: {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
existing: VendorPortalSignoff[];
|
||||
signoffType: string;
|
||||
nameLabel: string;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
||||
const [typedSig, setTypedSig] = useState("");
|
||||
const [drawnSig, setDrawnSig] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
||||
|
||||
if (existingSignoff) {
|
||||
return (
|
||||
<div>
|
||||
<div className="vp-row">
|
||||
<strong>Signed by</strong>
|
||||
<span>{existingSignoff.name}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Method</strong>
|
||||
<span>{existingSignoff.signatureMethod}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Signed at</strong>
|
||||
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||
if (!name.trim() || !signature) {
|
||||
alert("Name and signature are required");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.addSignoff(token, dispatchId, {
|
||||
name: name.trim(),
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
signoffType,
|
||||
});
|
||||
onAdded();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Failed to submit signoff");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<div className="vp-row">
|
||||
<strong>{nameLabel}</strong>
|
||||
<input
|
||||
className="vp-input"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Method</strong>
|
||||
<div>
|
||||
<label style={{ marginRight: 12 }}>
|
||||
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
|
||||
Drawn
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
|
||||
Typed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{method === "drawn" ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<SignaturePad onChange={setDrawnSig} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="vp-row">
|
||||
<strong>Signature</strong>
|
||||
<input
|
||||
className="vp-input"
|
||||
value={typedSig}
|
||||
onChange={(event) => setTypedSig(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
||||
{submitting ? "Signing…" : "Submit Signoff"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VendorPortalDispatchPage() {
|
||||
const { token } = useVendorPortal();
|
||||
const { id = "" } = useParams();
|
||||
const dispatchId = Number(id);
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
||||
|
|
@ -413,29 +30,18 @@ export default function VendorPortalDispatchPage() {
|
|||
|
||||
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
setActionError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Action failed");
|
||||
} catch {
|
||||
setActionError("Unable to update this dispatch. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <div className="vp-card vp-muted">Loading dispatch…</div>;
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return <div className="vp-error">{error.message}</div>;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const locked = data.status === "Verified" || data.status === "Cancelled";
|
||||
|
||||
const updateLocal = (
|
||||
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||
) => {
|
||||
|
|
@ -448,195 +54,59 @@ export default function VendorPortalDispatchPage() {
|
|||
);
|
||||
};
|
||||
|
||||
const requestCancel = () => {
|
||||
const reason = window.prompt("Reason for cancel request?");
|
||||
if (reason == null) return;
|
||||
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
|
||||
};
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<Text variant="feedback" className="vp-card vp-muted">
|
||||
Loading dispatch…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return <Text variant="error">Unable to load this dispatch. Please try again.</Text>;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const locked = data.status === "Verified" || data.status === "Cancelled";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
|
||||
← Back to dispatches
|
||||
</Link>
|
||||
<DispatchSummaryCard
|
||||
token={token}
|
||||
data={data}
|
||||
locked={locked}
|
||||
busy={busy}
|
||||
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
||||
onStart={() =>
|
||||
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
||||
}
|
||||
onComplete={() =>
|
||||
void runAction(
|
||||
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
|
||||
"Mark this dispatch as completed?",
|
||||
)
|
||||
}
|
||||
onRequestCancel={requestCancel}
|
||||
/>
|
||||
<Text variant="error" when={Boolean(actionError)}>
|
||||
{actionError}
|
||||
</Text>
|
||||
|
||||
<div className="vp-card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h2>{data.dispatchNumber}</h2>
|
||||
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
||||
</div>
|
||||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{data.workOrder?.internalWONumber && (
|
||||
<div className="vp-row">
|
||||
<strong>WO #</strong>
|
||||
<span>{data.workOrder.internalWONumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.poNumber && (
|
||||
<div className="vp-row">
|
||||
<strong>PO #</strong>
|
||||
<span>{data.poNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.nteAmount != null && (
|
||||
<div className="vp-row">
|
||||
<strong>NTE</strong>
|
||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.scheduledDate && (
|
||||
<div className="vp-row">
|
||||
<strong>Scheduled</strong>
|
||||
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.location && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 16 }}>Location</h3>
|
||||
<div>{data.location.name}</div>
|
||||
<div className="vp-muted">
|
||||
{[
|
||||
data.location.address,
|
||||
data.location.city,
|
||||
data.location.state,
|
||||
data.location.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(data.description || data.workOrder?.description) && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 16 }}>Description</h3>
|
||||
<div>{data.description || data.workOrder?.description}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="vp-actions">
|
||||
{data.status === "Sent" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-success"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
||||
>
|
||||
Accept Dispatch
|
||||
</button>
|
||||
)}
|
||||
{data.status === "Acknowledged" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
||||
}
|
||||
>
|
||||
Start Work
|
||||
</button>
|
||||
)}
|
||||
{data.status === "In Progress" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
|
||||
"Mark this dispatch as completed?",
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark Completed
|
||||
</button>
|
||||
)}
|
||||
{!locked && data.status !== "Sent" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-warn"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Reason for cancel request?");
|
||||
if (reason == null) return;
|
||||
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
|
||||
}}
|
||||
>
|
||||
Request Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Checklist</h3>
|
||||
<ChecklistSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
items={data.checklist ?? []}
|
||||
locked={locked}
|
||||
onItemUpdated={(updated) =>
|
||||
updateLocal((current) => ({
|
||||
...current,
|
||||
checklist: (current.checklist ?? []).map((item) =>
|
||||
item.id === updated.id ? { ...item, ...updated } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>NTE Uplift Requests</h3>
|
||||
<UpliftRequestsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
currentNte={data.nteAmount}
|
||||
upliftRequests={data.upliftRequests ?? []}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Customer Signoff</h3>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
existing={data.signoffs ?? []}
|
||||
signoffType="customer"
|
||||
nameLabel="Customer name"
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Vendor Signoff</h3>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
existing={data.signoffs ?? []}
|
||||
signoffType="vendor"
|
||||
nameLabel="Your name"
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Comments</h3>
|
||||
<CommentsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
comments={data.comments ?? []}
|
||||
onAdded={(comment) =>
|
||||
updateLocal((current) => ({
|
||||
...current,
|
||||
comments: [...(current.comments ?? []), comment],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DispatchDetailSections
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
data={data}
|
||||
locked={locked}
|
||||
refresh={refresh}
|
||||
updateLocal={updateLocal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
70
src/app/v/[token]/dispatch/_components/checklist-section.tsx
Normal file
70
src/app/v/[token]/dispatch/_components/checklist-section.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type ChecklistSectionProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
items: VendorPortalChecklistItem[];
|
||||
locked: boolean;
|
||||
onItemUpdated: (item: VendorPortalChecklistItem) => void;
|
||||
};
|
||||
|
||||
export function ChecklistSection({
|
||||
token,
|
||||
dispatchId,
|
||||
items,
|
||||
locked,
|
||||
onItemUpdated,
|
||||
}: ChecklistSectionProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const toggle = async (item: VendorPortalChecklistItem) => {
|
||||
if (locked) return;
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const updated = await vendorPortalApi.updateChecklistItem(
|
||||
token,
|
||||
dispatchId,
|
||||
item.id,
|
||||
!item.isCompleted,
|
||||
);
|
||||
onItemUpdated(updated);
|
||||
} catch {
|
||||
setErrorMessage("Unable to update the checklist. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!items.length) {
|
||||
return <Text tone="muted">No checklist items for this dispatch.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`cl-${item.id}`}
|
||||
checked={item.isCompleted}
|
||||
onChange={() => void toggle(item)}
|
||||
disabled={locked}
|
||||
/>
|
||||
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
||||
{item.itemText}
|
||||
</label>
|
||||
{item.isCompleted && Boolean(item.completedAt) && (
|
||||
<span className="vp-muted" style={{ fontSize: 12 }}>
|
||||
{formatVendorPortalDateTime(item.completedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/v/[token]/dispatch/_components/comments-section.tsx
Normal file
71
src/app/v/[token]/dispatch/_components/comments-section.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type CommentsSectionProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
comments: VendorPortalComment[];
|
||||
onAdded: (comment: VendorPortalComment) => void;
|
||||
};
|
||||
|
||||
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
setErrorMessage("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
||||
onAdded(added);
|
||||
setText("");
|
||||
} catch {
|
||||
setErrorMessage("Unable to post your comment. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.length === 0 && <Text tone="muted">No comments yet.</Text>}
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
|
||||
<div className="vp-comment-meta">
|
||||
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
|
||||
</div>
|
||||
<div>{comment.commenttext}</div>
|
||||
</div>
|
||||
))}
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<textarea
|
||||
className="vp-textarea"
|
||||
placeholder="Add a comment for the dispatcher…"
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{submitting ? "Posting…" : "Post Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist-section";
|
||||
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type DispatchDetailSectionsProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
data: VendorPortalDispatchDetail;
|
||||
locked: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
updateLocal: (
|
||||
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function DispatchDetailSections({
|
||||
token,
|
||||
dispatchId,
|
||||
data,
|
||||
locked,
|
||||
refresh,
|
||||
updateLocal,
|
||||
}: DispatchDetailSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Checklist</Text>
|
||||
<ChecklistSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
items={data.checklist ?? []}
|
||||
locked={locked}
|
||||
onItemUpdated={(updated) =>
|
||||
updateLocal((current) => ({
|
||||
...current,
|
||||
checklist: (current.checklist ?? []).map((item) =>
|
||||
item.id === updated.id ? { ...item, ...updated } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Completion documents</Text>
|
||||
<VendorPortalDocuments
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
documents={data.documents ?? []}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">NTE Uplift Requests</Text>
|
||||
<UpliftRequestsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
currentNte={data.nteAmount}
|
||||
upliftRequests={data.upliftRequests ?? []}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Customer Signoff</Text>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
existing={data.signoffs ?? []}
|
||||
signoffType="customer"
|
||||
nameLabel="Customer name"
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Vendor Signoff</Text>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
existing={data.signoffs ?? []}
|
||||
signoffType="vendor"
|
||||
nameLabel="Your name"
|
||||
onAdded={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<Text variant="heading">Comments</Text>
|
||||
<CommentsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
comments={data.comments ?? []}
|
||||
onAdded={(comment) =>
|
||||
updateLocal((current) => ({
|
||||
...current,
|
||||
comments: [...(current.comments ?? []), comment],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
type DispatchStatusActionsProps = {
|
||||
status: string;
|
||||
locked: boolean;
|
||||
busy: boolean;
|
||||
onAccept: () => void;
|
||||
onStart: () => void;
|
||||
onComplete: () => void;
|
||||
onRequestCancel: () => void;
|
||||
};
|
||||
|
||||
export function DispatchStatusActions({
|
||||
status,
|
||||
locked,
|
||||
busy,
|
||||
onAccept,
|
||||
onStart,
|
||||
onComplete,
|
||||
onRequestCancel,
|
||||
}: DispatchStatusActionsProps) {
|
||||
return (
|
||||
<div className="vp-actions">
|
||||
{status === "Sent" && (
|
||||
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}>
|
||||
Accept Dispatch
|
||||
</button>
|
||||
)}
|
||||
{status === "Acknowledged" && (
|
||||
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>
|
||||
Start Work
|
||||
</button>
|
||||
)}
|
||||
{status === "In Progress" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={busy}
|
||||
onClick={onComplete}
|
||||
>
|
||||
Mark Completed
|
||||
</button>
|
||||
)}
|
||||
{!locked && status !== "Sent" && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-warn"
|
||||
disabled={busy}
|
||||
onClick={onRequestCancel}
|
||||
>
|
||||
Request Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/v/[token]/dispatch/_components/dispatch-summary-card.tsx
Normal file
124
src/app/v/[token]/dispatch/_components/dispatch-summary-card.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Link } from "react-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
vendorPortalStatusClass,
|
||||
} from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
|
||||
|
||||
type DispatchSummaryCardProps = {
|
||||
token: string;
|
||||
data: VendorPortalDispatchDetail;
|
||||
locked: boolean;
|
||||
busy: boolean;
|
||||
onAccept: () => void;
|
||||
onStart: () => void;
|
||||
onComplete: () => void;
|
||||
onRequestCancel: () => void;
|
||||
};
|
||||
|
||||
export function DispatchSummaryCard({
|
||||
token,
|
||||
data,
|
||||
locked,
|
||||
busy,
|
||||
onAccept,
|
||||
onStart,
|
||||
onComplete,
|
||||
onRequestCancel,
|
||||
}: DispatchSummaryCardProps) {
|
||||
return (
|
||||
<>
|
||||
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
|
||||
← Back to dispatches
|
||||
</Link>
|
||||
|
||||
<div className="vp-card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<Text variant="title">{data.dispatchNumber}</Text>
|
||||
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
||||
</div>
|
||||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>WO #</strong>
|
||||
<span>{data.workOrder.internalWONumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(data.poNumber) && (
|
||||
<div className="vp-row">
|
||||
<strong>PO #</strong>
|
||||
<span>{data.poNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.nteAmount != null && (
|
||||
<div className="vp-row">
|
||||
<strong>NTE</strong>
|
||||
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="vp-row">
|
||||
<strong>Invoice</strong>
|
||||
<span>
|
||||
{data.invoiceStatus || "Not Submitted"}
|
||||
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Payment</strong>
|
||||
<span>{data.paymentStatus || "Unavailable"}</span>
|
||||
</div>
|
||||
{Boolean(data.scheduledDate) && (
|
||||
<div className="vp-row">
|
||||
<strong>Scheduled</strong>
|
||||
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.location != null && (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Location
|
||||
</Text>
|
||||
<div>{data.location.name}</div>
|
||||
<div className="vp-muted">
|
||||
{[
|
||||
data.location.address,
|
||||
data.location.city,
|
||||
data.location.state,
|
||||
data.location.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Boolean(data.description || data.workOrder?.description) && (
|
||||
<>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<div>{data.description || data.workOrder?.description}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DispatchStatusActions
|
||||
status={data.status}
|
||||
locked={locked}
|
||||
busy={busy}
|
||||
onAccept={onAccept}
|
||||
onStart={onStart}
|
||||
onComplete={onComplete}
|
||||
onRequestCancel={onRequestCancel}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
135
src/app/v/[token]/dispatch/_components/signoff-section.tsx
Normal file
135
src/app/v/[token]/dispatch/_components/signoff-section.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||
import type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type SignoffSectionProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
existing: VendorPortalSignoff[];
|
||||
signoffType: string;
|
||||
nameLabel: string;
|
||||
onAdded: () => void;
|
||||
};
|
||||
|
||||
export function SignoffSection({
|
||||
token,
|
||||
dispatchId,
|
||||
existing,
|
||||
signoffType,
|
||||
nameLabel,
|
||||
onAdded,
|
||||
}: SignoffSectionProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
||||
const [typedSig, setTypedSig] = useState("");
|
||||
const [drawnSig, setDrawnSig] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
||||
|
||||
if (existingSignoff) {
|
||||
return (
|
||||
<div>
|
||||
<div className="vp-row">
|
||||
<strong>Signed by</strong>
|
||||
<span>{existingSignoff.name}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Method</strong>
|
||||
<span>{existingSignoff.signatureMethod}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Signed at</strong>
|
||||
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||
if (!name.trim() || !signature) {
|
||||
setErrorMessage("Name and signature are required.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.addSignoff(token, dispatchId, {
|
||||
name: name.trim(),
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
signoffType,
|
||||
});
|
||||
onAdded();
|
||||
} catch {
|
||||
setErrorMessage("Unable to submit the signoff. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<div className="vp-row">
|
||||
<strong>{nameLabel}</strong>
|
||||
<input
|
||||
className="vp-input"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Method</strong>
|
||||
<div>
|
||||
<label style={{ marginRight: 12 }}>
|
||||
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
|
||||
Drawn
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
|
||||
Typed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{method === "drawn" ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<SignaturePad
|
||||
onChange={(signature) => {
|
||||
setDrawnSig(signature);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vp-row">
|
||||
<strong>Signature</strong>
|
||||
<input
|
||||
className="vp-input"
|
||||
value={typedSig}
|
||||
onChange={(event) => {
|
||||
setTypedSig(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
||||
{submitting ? "Signing…" : "Submit Signoff"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type UpliftRequestsSectionProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
currentNte?: number;
|
||||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
};
|
||||
|
||||
function validateUpliftRequest(requested: string, currentNte: number | undefined, reason: string) {
|
||||
const amount = Number.parseFloat(requested);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return "Enter a valid requested NTE amount.";
|
||||
}
|
||||
if (currentNte != null && amount <= currentNte) {
|
||||
return `Requested NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
return "Please include a reason for the uplift request.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function UpliftRequestsSection({
|
||||
token,
|
||||
dispatchId,
|
||||
currentNte,
|
||||
upliftRequests,
|
||||
locked,
|
||||
onChanged,
|
||||
}: UpliftRequestsSectionProps) {
|
||||
const [requested, setRequested] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const pending = upliftRequests.find((request) => request.status === "Pending");
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const amount = Number.parseFloat(requested);
|
||||
const validationError = validateUpliftRequest(requested, currentNte, reason);
|
||||
setErrorMessage(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.requestUplift(token, dispatchId, {
|
||||
requestedNTE: amount,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
setRequested("");
|
||||
setReason("");
|
||||
await onChanged();
|
||||
} catch {
|
||||
setErrorMessage("Unable to request the NTE uplift. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async (requestId: number) => {
|
||||
if (!window.confirm("Cancel this uplift request?")) return;
|
||||
setErrorMessage("");
|
||||
try {
|
||||
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
|
||||
await onChanged();
|
||||
} catch {
|
||||
setErrorMessage("Unable to cancel the NTE uplift request. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{upliftRequests.length === 0 && !pending && (
|
||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upliftRequests.map((request) => (
|
||||
<div key={request.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>Requested</strong>
|
||||
<span>
|
||||
${Number(request.requestedNTE).toFixed(2)} (was $
|
||||
{Number(request.currentNTE ?? 0).toFixed(2)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Status</strong>
|
||||
<span>
|
||||
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{Boolean(request.vendorReason) && (
|
||||
<div className="vp-row">
|
||||
<strong>Reason</strong>
|
||||
<span>{request.vendorReason}</span>
|
||||
</div>
|
||||
)}
|
||||
{request.status === "Pending" && (
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
onClick={() => void cancel(request.id)}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
|
||||
{!locked && !pending && (
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<div className="vp-row">
|
||||
<strong>Current NTE</strong>
|
||||
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>New total NTE</strong>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="vp-input"
|
||||
value={requested}
|
||||
onChange={(event) => {
|
||||
setRequested(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Reason</strong>
|
||||
<textarea
|
||||
className="vp-textarea"
|
||||
value={reason}
|
||||
onChange={(event) => {
|
||||
setReason(event.target.value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
placeholder="Why is the uplift needed?"
|
||||
/>
|
||||
</div>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Request Uplift"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
162
src/app/v/_components/vendor-portal-documents.tsx
Normal file
162
src/app/v/_components/vendor-portal-documents.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type VendorPortalDocumentsProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
documents: VendorPortalDocument[];
|
||||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
};
|
||||
|
||||
type UploadRequest = {
|
||||
file: File;
|
||||
replacementId?: number;
|
||||
};
|
||||
|
||||
export function VendorPortalDocuments({
|
||||
token,
|
||||
dispatchId,
|
||||
documents,
|
||||
locked,
|
||||
onChanged,
|
||||
}: VendorPortalDocumentsProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [replacementId, setReplacementId] = useState<number>();
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||
vendorPortalApi.uploadDocument({
|
||||
token,
|
||||
dispatchId,
|
||||
file,
|
||||
replacesDocumentId: requestedReplacementId,
|
||||
onProgress: setProgress,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onSettled: () => {
|
||||
setProgress(undefined);
|
||||
setReplacementId(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const chooseFile = (documentId?: number) => {
|
||||
setValidationError(undefined);
|
||||
uploadMutation.reset();
|
||||
setReplacementId(documentId);
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const uploadSelectedFile = (file?: File) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationMessage = validateVendorDocument(file);
|
||||
if (validationMessage) {
|
||||
setValidationError(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(undefined);
|
||||
setProgress(0);
|
||||
uploadMutation.mutate({ file, replacementId });
|
||||
};
|
||||
|
||||
const uploadError =
|
||||
validationError ??
|
||||
(uploadMutation.error ? "Unable to upload the document. Please try again." : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
aria-label="Choose completion document"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
hidden
|
||||
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{documents.length === 0 && (
|
||||
<VendorPortalFeedback>No completion documents uploaded.</VendorPortalFeedback>
|
||||
)}
|
||||
|
||||
{documents.map((document) => (
|
||||
<article key={document.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => chooseFile(document.id)}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{progress !== undefined && (
|
||||
<div role="status" aria-live="polite">
|
||||
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(uploadError) && (
|
||||
<VendorPortalFeedback tone="error">{uploadError}</VendorPortalFeedback>
|
||||
)}
|
||||
|
||||
{!locked && (
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => chooseFile()}
|
||||
>
|
||||
Upload document
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
src/app/v/_components/vendor-portal-feedback.tsx
Normal file
19
src/app/v/_components/vendor-portal-feedback.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type VendorPortalFeedbackProps = {
|
||||
children: ReactNode;
|
||||
tone?: "error" | "muted";
|
||||
};
|
||||
|
||||
export function VendorPortalFeedback({ children, tone = "muted" }: VendorPortalFeedbackProps) {
|
||||
return (
|
||||
<Text
|
||||
variant={tone === "error" ? "error" : "feedback"}
|
||||
tone={tone}
|
||||
className={tone === "error" ? "vp-error" : "vp-muted"}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function FormField({
|
|||
{label}
|
||||
</FormLabel>
|
||||
{children}
|
||||
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||
{Boolean(helperText) && <FormHelperText>{helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
104
src/components/ui/text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import {
|
|||
ClipboardList,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Bell,
|
||||
BarChart3,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavItemDef = {
|
||||
|
|
@ -34,6 +37,7 @@ export const navGroups: NavGroup[] = [
|
|||
items: [
|
||||
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
|
||||
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
||||
{ label: "Notifications", icon: Bell, path: "/notifications" },
|
||||
// { label: "Reports", icon: BarChart2, path: "/reports" },
|
||||
],
|
||||
},
|
||||
|
|
@ -42,6 +46,8 @@ export const navGroups: NavGroup[] = [
|
|||
items: [
|
||||
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
||||
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
|
||||
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
|
||||
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
81
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal file
81
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { api, apiGet, apiPut } from "@/api/api";
|
||||
import type {
|
||||
SiteVendorPreference,
|
||||
VendorAssignmentResult,
|
||||
VendorAvailability,
|
||||
VendorAssignmentPayload,
|
||||
VendorInsight,
|
||||
VendorNotification,
|
||||
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||
|
||||
const base = "vendor-operations";
|
||||
|
||||
function searchParams(values: Record<string, string | number | undefined>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(
|
||||
(entry): entry is [string, string | number] => entry[1] !== undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type InsightParams = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
locationId?: string | number;
|
||||
trade?: string;
|
||||
vendorId?: string | number;
|
||||
};
|
||||
|
||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const vendorOperationsApi = {
|
||||
notifications: async (): Promise<VendorNotification[]> => {
|
||||
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
|
||||
`${base}/notifications`,
|
||||
);
|
||||
return response.items ?? response.Items ?? [];
|
||||
},
|
||||
|
||||
availability: async (params: {
|
||||
start?: string;
|
||||
end?: string;
|
||||
locationId?: string | number;
|
||||
trade?: string;
|
||||
}): Promise<VendorAvailability[]> => {
|
||||
const response = await apiGet<{ data?: VendorAvailability[]; Data?: VendorAvailability[] }>(
|
||||
`${base}/availability`,
|
||||
{ searchParams: searchParams(params) },
|
||||
);
|
||||
return response.data ?? response.Data ?? [];
|
||||
},
|
||||
|
||||
insights: async (params: InsightParams): Promise<VendorInsight[]> => {
|
||||
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
|
||||
`${base}/insights`,
|
||||
{ searchParams: searchParams(params) },
|
||||
);
|
||||
return response.data ?? response.Data ?? [];
|
||||
},
|
||||
|
||||
downloadInsights: (format: "csv" | "pdf", params: InsightParams) =>
|
||||
saveDownload(`insights.${format}`, `vendor-insights.${format}`, params),
|
||||
|
||||
sitePreferences: (locationId: string | number) =>
|
||||
apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`),
|
||||
|
||||
replaceSitePreferences: (
|
||||
locationId: string | number,
|
||||
preferences: Array<{ vendorId: number; trade?: string; sortOrder: number }>,
|
||||
) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences),
|
||||
|
||||
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
|
||||
apiPut<VendorAssignmentResult>(`${base}/work-orders/${workOrderId}/assignment`, payload),
|
||||
};
|
||||
79
src/domain/vendor-operations/types/vendor-operations.ts
Normal file
79
src/domain/vendor-operations/types/vendor-operations.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export interface VendorNotificationAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface VendorNotification {
|
||||
id: string;
|
||||
type: "NoVendor" | "VendorConflict";
|
||||
severity: "Medium";
|
||||
group: "No Vendor" | "Vendor Conflict";
|
||||
title: string;
|
||||
workOrderId?: number;
|
||||
workOrderNumber?: string;
|
||||
triggeredAt: string;
|
||||
scheduledAt?: string;
|
||||
action?: VendorNotificationAction;
|
||||
actions?: VendorNotificationAction[];
|
||||
workOrders?: Array<{
|
||||
workOrderId: number;
|
||||
workOrderNumber?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VendorAvailability {
|
||||
id: number;
|
||||
companyName: string;
|
||||
contactName?: string;
|
||||
tradeSpecialties?: string;
|
||||
availabilityStatus: "Available" | "Unavailable" | "Unknown";
|
||||
availabilityUpdatedAt?: string;
|
||||
isPreferred: boolean;
|
||||
preferenceRank?: number;
|
||||
conflicts: Array<{
|
||||
workOrderId: number;
|
||||
workOrderNumber?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VendorInsight {
|
||||
vendorId: number;
|
||||
vendor: string;
|
||||
completedJobs: number;
|
||||
cancellationRefusalRate: number;
|
||||
onTimeArrivalRate: number;
|
||||
onTimeCompletionRate: number;
|
||||
averageCycleHours?: number;
|
||||
}
|
||||
|
||||
export interface SiteVendorPreference {
|
||||
id: number;
|
||||
locationId: number;
|
||||
vendorId: number;
|
||||
vendorName: string;
|
||||
trade?: string;
|
||||
sortOrder: number;
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
export interface VendorAssignmentPayload {
|
||||
vendorId: number;
|
||||
scheduledStart?: string;
|
||||
scheduledEnd?: string;
|
||||
estimatedArrivalAt?: string;
|
||||
etaManualOverride: boolean;
|
||||
}
|
||||
|
||||
export interface VendorAssignmentResult {
|
||||
availabilityStatus: "Available" | "Unavailable" | "Unknown";
|
||||
conflicts: Array<{
|
||||
workOrderId: number;
|
||||
workOrderNumber?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
VendorPortalDispatchSummary,
|
||||
VendorPortalDocument,
|
||||
VendorPortalSession,
|
||||
VendorPortalSignoff,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
|
@ -14,6 +15,66 @@ function resolveVendorPortalUrl(path: string): string {
|
|||
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
|
||||
}
|
||||
|
||||
type UploadVendorDocumentOptions = {
|
||||
token: string;
|
||||
dispatchId: string | number;
|
||||
file: File;
|
||||
replacesDocumentId?: number;
|
||||
onProgress: (percent: number) => void;
|
||||
};
|
||||
|
||||
function uploadVendorDocument({
|
||||
token,
|
||||
dispatchId,
|
||||
file,
|
||||
replacesDocumentId,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadVendorDocument(
|
||||
token: string,
|
||||
dispatchId: string | number,
|
||||
document: VendorPortalDocument,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
|
|
@ -131,4 +192,7 @@ export const vendorPortalApi = {
|
|||
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
uploadDocument: uploadVendorDocument,
|
||||
downloadDocument: downloadVendorDocument,
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue