fix(vendors): merge dev and address review

This commit is contained in:
Alexandre Brandizzi 2026-07-24 17:30:36 -03:00
commit 900af790c0
129 changed files with 8379 additions and 1512 deletions

View file

@ -11,6 +11,33 @@ permissions:
jobs:
ci:
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
with:
node-version: "24"
governance:
# Repo-owned guarantee that every frontend quality gate runs from this
# repository, independent of (and in addition to) the reusable workflow.
# `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
# maintainability gate). If the reusable workflow is later confirmed to run
# every gate, this job can be slimmed to `npm run governance`.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
# push-> the previous commit on the branch (github.event.before)
runs-on: ubuntu-latest
env:
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run verify

81
AGENTS.md Normal file
View file

@ -0,0 +1,81 @@
# AGENTS.md — frontend mandatory conventions
This file is the binding entry point for **every contributor — human or AI coding
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
frontend). It makes the validated React/TypeScript conventions mandatory and
points to the operational documents and executable gates that enforce them.
Read these before writing or reviewing code. They override generic "best
practice" suggestions from any agent or model. This repository contract may
strengthen, but never weaken, the workspace-level `AGENTS.md`.
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
command, and the no-false-pass guarantees.
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
data access, security, maintainability) with evidence and thresholds.
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
contract (exact-head review, board regression inventory, behavior-based
testing, security/performance/Big-O review, no style-only comments).
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
detailed rationale for the conditional-rendering and typography rules.
## One command to run every gate
```bash
npm run verify
```
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
checks (`npm run governance`). **Do not claim a task is done until `npm run
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
`governance` job, so a green local run mirrors CI.
## Non-negotiable rules (enforced; do not work around)
These are already enforced by lint/build or the governance script. Disabling,
baselining, or per-line-suppressing them is forbidden (see exceptions below).
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
the `vp-error` class outside `Text` are rejected.
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
do not silence it.
- **Hooks correctness** — the `react-hooks` recommended rules (including
`exhaustive-deps`) run under the zero-warnings gate.
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
has a frozen per-file cap that may only decrease.
- **Changed-file maintainability** — changed TS/TSX must meet
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
## How to add / change a convention
1. Land it **green**: a new or tightened rule must ship with the codebase passing
it (a migration in the same change), not as a warning-only backlog.
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
new baseline debt or raise a frozen cap.
3. Document the rule, its threshold, and its evidence in
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
## Exceptions and the ADR process
Exceptions are **not granted by disabling a rule inline**. To deviate:
1. Record an **Architecture Decision Record** under `docs/adr/`
(`NNNN-title.md`: context, decision, consequences, alternatives).
2. Existing grandfathered entries may only be removed or have their caps
reduced. New entries and cap increases fail the governance gate.
3. Get it reviewed like any other change. The ADR + baseline entry is the
auditable record; an `eslint-disable` comment is not.
## Toolchain (do not change without an ADR)
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
`packageManager`, invoked through corepack). **No new dependencies without an
ADR** — prefer the libraries already established (see
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
data access).

View file

@ -0,0 +1,125 @@
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
Mandatory React/TypeScript conventions for this repository. Each rule lists how
it is enforced. Detailed rationale for rendering and typography lives in
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
means it is a review-enforced convention backed by the
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
## Conditional rendering
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
semantics and TypeScript narrowing with `count > 0`, `value != null`.
`{count && <X/>}` renders `0` and is rejected by the type-aware
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
the approved guard forms.)
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
## Typography and feedback
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
`vp-error` class on any non-`Text` element is rejected
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
bypass the static check.
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
omitted from `TextProps` so the variant contract (element, tone, live region)
cannot be overridden. Use `as` and `tone`.
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
with `when`; mount `error` (assertive `alert`) on demand.
## Forms and mutations
- **MUST** use the libraries already established: **React Hook Form** for field
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
Query** for server reads and mutations (pending/error state, invalidation,
retries).
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
second form convention increases cognitive and dependency cost. Replacing
React Hook Form requires an approved repo-wide migration ADR with measured
benefits, a codemod/migration plan, and removal of the superseded dependency.
- **SHOULD** keep file upload selection/validation in a focused component and
drive upload progress/errors/retry through a TanStack Query mutation, not in a
route-sized page.
## State ownership (bounded)
- **MUST** keep query loading/error/empty state adjacent to the query result;
mutation pending/error state in the component that initiated the mutation.
- **MUST** compose focused state components instead of accumulating unrelated
booleans in a page. Route pages coordinate sections and navigation; reusable
sections own their interaction details.
- **SHOULD** render errors inline with accessible feedback (`Text
variant="error"`); reserve toasts for cross-page outcomes.
## Data access
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
break caching and invalidation. (Review-enforced; a future lint rule is
tracked as a gap.)
- **MUST NOT** materialize large server collections into client state and then
filter/sort them in the component when the server (or a memoized, virtualized
layer) should own it. Prefer server-side filtering/pagination; if client-side
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
- **MUST** invalidate the right query keys after a mutation so caches do not
show stale data.
## Security
- **MUST NOT** surface raw server error payloads, stack traces, or internal
identifiers to end users. Map server errors to a safe user-facing message
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
(Review-enforced.)
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
never put a secret in a `VITE_` variable.
## Hooks, performance, and Big-O
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
under the zero-warnings gate.
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
data, key lists stably, and prefer server-side filtering for large sets
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
only when measured to matter (no speculative memoization).
## Maintainability (measured thresholds)
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
These are conservative code-shape proxies for review focus. File length is not
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
judgment.
| Metric | Threshold | How enforced |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
**Why these are changed-file ratchets, not whole-repo errors:** measured against
the current codebase, the maintainability thresholds surface **72 violations
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
legacy without a migration. Instead:
- The **5 oversized files** are explicit grandfathered debt with frozen
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
grow; new entries and cap increases fail.
- The **complexity/function-length/params/depth** rules apply to **changed**
TS/TSX on a PR/push, so new and modified code must comply while untouched
legacy is not blocked. Bring legacy into compliance when you next touch it.
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
entries) as debt is paid down. Never raise them to make a change pass.

54
QUALITY_GATES.md Normal file
View file

@ -0,0 +1,54 @@
# QUALITY_GATES.md — executable frontend gates
The single command that runs **every** gate, locally and in CI:
```bash
npm run verify
```
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. A task is not done until this is green.
## Gate matrix
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
## No-false-pass guarantees
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
backlog; rules ship green (see AGENTS.md → How to add a convention).
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
when type services are unavailable rather than silently claiming safety.
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
entry, global cap increase, per-file cap increase, or growth beyond a frozen
legacy cap fails. Only cap reductions and entry removals are allowed.
- **Changed-file maintainability fails closed without a valid base** — in CI the
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
(push). An absent or unresolvable base is a failure, not a pass.
## Where the gates run
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
Prettier on staged files at commit; commitlint enforces Conventional Commits.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
format/lint/build/tests, **and** a repo-owned `governance` job runs
`npm run verify` so the maintainability ratchets are guaranteed from this
repository regardless of the reusable workflow.
## Toolchain pin
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
`corepack npm …` if your default `npm` is older). The lockfile is
`package-lock.json` v3; install with `npm ci`.

View file

@ -95,15 +95,17 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
the deployed dev API).
| Command | Description |
| ------------------------------------------ | ----------------------------------------------------- |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| Command | Description |
| ------------------------------------------ | -------------------------------------------------------- |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
Husky + lint-staged run ESLint and Prettier on staged files at commit;
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
@ -132,7 +134,13 @@ CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests.
format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
guaranteed from this repository. Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)

View file

@ -0,0 +1,81 @@
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
Every PR is reviewed against this contract, by humans and by review agents. The
goal is high-signal review: catch real behavior, security, performance, and
regression problems — not re-lint what the gates already enforce.
## 1. Review the exact head
- **MUST** review the diff at the **current PR head**, not a stale checkout.
Re-pull before reviewing if new commits landed; stale approvals are dismissed
on push by branch protection.
- **MUST** read the full diff of every changed file, including renames and
generated/mapper code, not only the "interesting" components.
## 2. Board-backed regression inventory
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
all visible tickets for this area, not only the current ticket. Behavior that
is Done / QA-approved / released is **protected scope**.
- **MUST** treat a plausible regression against protected behavior as a
**Blocker** until disproven with repo evidence (the accepting tests, the linked
PR/release, and a targeted check of the changed code paths).
- **MUST NOT** approve if board access or the relevant inventory is missing —
say so and block rather than infer a pass. A missing ticket link alone is not
a blocker, but known acceptance criteria must still be traced.
The current Jira workflow has no `Ready for QA` transition. Merged work remains
in the documented pre-QA status until QA evidence supports `Done`; do not invent
a status or mark unverified work Done.
## 3. Behavior-based testing
- **MUST** test through **public behavior** (rendered output, user interactions,
query/mutation outcomes), not internal implementation details. Prefer
`@testing-library` queries and user-event flows; assert what users observe.
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
governance script) executes — validate tooling by running the real gates
(`npm run verify`), not with assertion-free unit tests.
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
error/empty state, and any boundary the change introduces.
## 4. Security review
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
`VITE_*` variable carries a secret (it is baked into the bundle).
- **SHOULD** check untrusted input is validated (Zod) before use and that
dangerously-set HTML / unescaped server strings are not introduced.
## 5. Performance and Big-O review
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
collections, re-filtering/sorting on every render, unbounded list rendering
without virtualization.
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
the correct keys (no stale cache, no redundant refetch storms).
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
unstable identities passed to memoized children.
## 6. No style-only comments
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
enforces (formatting, naming nits the linter catches). Style is settled by the
gates; review is for behavior, correctness, security, and architecture.
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
evidence-based convention in these docs, and offer a concrete fix or a
targeted question. Use GitHub suggestion blocks when safe.
## 7. Review close-out
A review is complete when it records, briefly:
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
"no findings".
2. Open questions and their owner.
3. Which of the above checks were run, and any that were skipped (and why).
4. Residual risk, if approving.
Do not write a monolithic review body or a validation transcript into the PR
surface; keep comments inline and high-signal.

View file

@ -0,0 +1,115 @@
# Frontend maintainability conventions
## Conditional rendering
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
state. Use a ternary only when both branches render meaningful alternatives.
```tsx
{
error && <Alert severity="error">{error.message}</Alert>;
}
<Text variant="description" when={Boolean(description)}>
{description}
</Text>;
```
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
distinct from real either-or UI decisions.
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
TypeScript checker for the type of the left operand and reports unless every union constituent is
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
fails closed: if type services are unavailable in a governed TSX file it reports rather than
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
same constraint at that component boundary.
Approved guard forms (choose by semantics, not as a blind codemod):
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
narrowing.
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
that needs a non-boolean operand must be rewritten into one of the approved forms above.
## Typography and feedback
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
code, and asynchronous feedback. It owns:
- semantic HTML for each visual variant;
- the display, body, and monospace font families;
- default, muted, error, success, and warning tones;
- accessible `alert` and `status` live regions for error and feedback text;
- one-sided conditional text through `when`.
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
`aria-live` directly.
Live-region behavior:
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
the polite region exists before its content changes and is announced reliably. Mounting an
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
static surface only. It cannot resolve dynamic or composed class values
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
## Forms and mutations
Use the libraries already established in the application:
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
- Zod owns form validation and inferred form value types.
- TanStack Query owns server reads and mutations, including pending/error state, cache
invalidation, and retries where safe.
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
benchmarks, a codemod plan, and an approved deprecation path.
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
retry state. Do not place upload orchestration in a route-sized page component.
## Page state
Pages should compose focused state components instead of accumulating unrelated booleans:
- query loading, error, and empty states stay adjacent to the query result;
- mutation pending/error state belongs to the component that initiated the mutation;
- route pages coordinate sections and navigation;
- reusable sections own their interaction details;
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
## Enforcement and rollout
The lint rules are repository-wide and the initial violations were migrated in the same change.
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
maintainability rules must also land with a green migration rather than a warning-only backlog.

View file

@ -304,6 +304,73 @@ test.describe("Vendor operations", () => {
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("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
page,
}) => {

View file

@ -0,0 +1,95 @@
import ts from "typescript";
/**
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
* boolean.
*
* `{value && <Element />}` renders its left operand when it is falsy and
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
* must be `boolean` (or boolean literals) across the whole union. The rule
* asks the TypeScript checker for the type of the left operand and reports
* unless every union constituent is boolean-like.
*
* Type-aware by design: a selector that cannot see operand types would give
* false confidence rather than enforcement. If type services are unexpectedly
* unavailable in a governed TSX file, the rule fails closed (reports) instead
* of silently claiming the expression is safe.
*/
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
function isBooleanLikeType(type) {
return (type.flags & booleanLikeFlags) !== 0;
}
function isEntirelyBoolean(type) {
if (type.isUnion()) {
return type.types.every((constituent) => isBooleanLikeType(constituent));
}
return isBooleanLikeType(type);
}
const transparentExpressionTypes = new Set([
"ChainExpression",
"ParenthesizedExpression",
"TSAsExpression",
"TSNonNullExpression",
"TSTypeAssertion",
]);
function isInRenderedPosition(node) {
let current = node;
while (current.parent) {
const parent = current.parent;
if (parent.type === "JSXExpressionContainer") {
return parent.parent?.type !== "JSXAttribute";
}
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
current = parent;
continue;
}
return false;
}
return false;
}
const rule = {
meta: {
type: "problem",
docs: {
description: "Require the left operand of `&&` in JSX to be entirely boolean",
},
schema: [],
messages: {
nonBooleanLeft:
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
typeServicesUnavailable:
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
},
},
create(context) {
const services = context.sourceCode?.parserServices ?? context.parserServices;
return {
LogicalExpression(node) {
if (node.operator !== "&&") {
return;
}
if (!isInRenderedPosition(node)) {
return;
}
if (!services || services.program === null) {
context.report({ node, messageId: "typeServicesUnavailable" });
return;
}
const leftType = services.getTypeAtLocation(node.left);
if (!isEntirelyBoolean(leftType)) {
context.report({ node: node.left, messageId: "nonBooleanLeft" });
}
},
};
},
};
export default rule;

View file

@ -0,0 +1,55 @@
function containsVpErrorToken(node) {
if (!node || typeof node !== "object") {
return false;
}
if (node.type === "Literal" && typeof node.value === "string") {
return node.value.split(/\s+/u).includes("vp-error");
}
if (node.type === "TemplateElement") {
return node.value.raw.split(/\s+/u).includes("vp-error");
}
return Object.entries(node).some(([key, value]) => {
if (key === "parent") {
return false;
}
if (Array.isArray(value)) {
return value.some(containsVpErrorToken);
}
return containsVpErrorToken(value);
});
}
const rule = {
meta: {
type: "problem",
docs: {
description: "Require the Text component for legacy vp-error styling",
},
schema: [],
messages: {
useText:
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
},
},
create(context) {
return {
JSXAttribute(node) {
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
return;
}
const elementName = node.parent?.name;
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
return;
}
context.report({ node, messageId: "useText" });
},
};
},
};
export default rule;

View file

@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
const localRules = {
rules: {
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
"no-vp-error-outside-text": noVpErrorOutsideText,
},
};
const legacyIgnores = [
"src/pages/**",
"src/app/store.js",
@ -54,6 +64,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@ -62,6 +73,23 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"seahaven/no-non-boolean-jsx-and": "error",
"seahaven/no-vp-error-outside-text": "error",
"no-restricted-syntax": [
"error",
{
selector:
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
message:
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
},
{
selector:
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
message:
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
},
],
},
},
{

View file

@ -11,10 +11,12 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"governance": "node scripts/governance-check.mjs",
"verify": "npm run format:check && npm run lint && npm run build && npm test && npm run governance",
"prepare": "husky"
},
"lint-staged": {

View file

@ -0,0 +1,27 @@
{
"version": 1,
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
"godfileDebt": [
{
"path": "src/app/(protected)/workorders/[id].tsx",
"maxLines": 797,
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
},
{
"path": "src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx",
"maxLines": 782,
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
},
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
},
{
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
"maxLines": 572,
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
}
]
}

View file

@ -0,0 +1,289 @@
import { execFileSync, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
const MAX_FILE_LINES = 500;
const MAINTAINABILITY_RULES = [
'complexity: ["error", { "max": 20 }]',
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
'max-params: ["error", 4]',
'max-depth: ["error", 4]',
];
const GOVERNED_ROOTS = ["src/", "config/"];
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
function isGoverned(relativePath) {
return (
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
/\.(ts|tsx)$/.test(relativePath) &&
!EXCLUDE_DIR.test(relativePath) &&
!EXCLUDE_NAME.test(relativePath)
);
}
function gitText(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
}
function gitLines(args) {
return gitText(args).split("\n").filter(Boolean);
}
function governedFiles() {
const tracked = gitLines(["ls-files"]);
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
return [...new Set([...tracked, ...untracked])].filter(isGoverned);
}
function lineCount(relativePath) {
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
if (content.length === 0) return 0;
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
function readBaseline() {
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
}
function readBaselineAtRef(ref) {
try {
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return JSON.parse(content);
} catch {
return null;
}
}
function godfileRatchet(baseRef) {
const baseline = readBaseline();
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
const debtEntries = new Map(
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
);
const files = governedFiles();
const newDebt = [];
const grownDebt = [];
for (const file of files) {
const lines = lineCount(file);
const debtCap = debtEntries.get(file);
if (lines > cap && debtCap === undefined) {
newDebt.push({ path: file, lines });
} else if (debtCap !== undefined && lines > debtCap) {
grownDebt.push({ path: file, lines, maxLines: debtCap });
}
}
const stale = [];
const remaining = [];
for (const [debtPath, maxLines] of debtEntries) {
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
if (lines === -1 || lines <= cap) {
stale.push({ path: debtPath, lines });
} else {
remaining.push({ path: debtPath, lines, maxLines });
}
}
const baselineLoosening = [];
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
if (baseBaseline) {
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
if (cap > baseCap) {
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
}
const baseEntries = new Map(
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
);
for (const [debtPath, maxLines] of debtEntries) {
const priorMax = baseEntries.get(debtPath);
if (priorMax === undefined) {
baselineLoosening.push(`new debt entry: ${debtPath}`);
} else if (maxLines > priorMax) {
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
}
}
}
return {
cap,
newDebt,
grownDebt,
stale,
remaining,
baselineLoosening,
comparedBaseline: Boolean(baseBaseline),
};
}
function resolveBaseRef() {
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
for (const candidate of ["origin/dev", "origin/main"]) {
try {
execFileSync("git", ["rev-parse", "--verify", candidate], {
cwd: ROOT,
encoding: "utf8",
stdio: "ignore",
});
return candidate;
} catch {
// candidate ref not present locally; try the next
}
}
return null;
}
function changedGovernedFiles(baseRef) {
let mergeBase;
try {
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
}
function maintainabilityGate(files) {
if (files.length === 0) {
return { skipped: true, reason: "no changed governed TS/TSX files" };
}
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
const result = spawnSync(
eslintBin,
[
...files,
...ruleArgs,
"--max-warnings=0",
"--no-warn-ignored",
"--no-error-on-unmatched-pattern",
],
{ cwd: ROOT, encoding: "utf8" },
);
return {
skipped: false,
status: result.status,
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() ?? "",
files,
};
}
function plural(count, word) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
if (!baseRef) {
failures.push(
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
try {
gitText(["merge-base", baseRef, "HEAD"]);
} catch {
failures.push(
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
);
}
}
console.log("─".repeat(64));
console.log("godfile ratchet: legacy caps may only shrink");
const god = godfileRatchet(baseRef);
console.log(
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
);
for (const entry of god.remaining) {
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
for (const entry of god.newDebt) {
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
}
for (const entry of god.grownDebt) {
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
if (god.newDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
);
}
if (god.grownDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
);
}
if (god.baselineLoosening.length > 0) {
failures.push(
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
);
}
if (!god.comparedBaseline) {
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
}
if (god.stale.length > 0) {
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
for (const entry of god.stale) {
console.log(` stale ${entry.path}`);
}
}
console.log("─".repeat(64));
if (!baseRef) {
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
} else {
const files = changedGovernedFiles(baseRef);
console.log(
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
);
if (files === null) {
console.log(" failed — base ref could not be resolved against HEAD");
} else {
const gate = maintainabilityGate(files);
if (gate.skipped) {
console.log(` skipped — ${gate.reason}`);
} else {
const clean = gate.status === 0;
console.log(
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
);
if (!clean) {
if (gate.stdout) console.log(gate.stdout);
if (gate.stderr) console.log(gate.stderr);
failures.push(
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
);
}
}
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
for (const failure of failures) console.log(` - ${failure}`);
process.exit(1);
}
console.log("RESULT: PASS — all governance gates green");
}
main();

View file

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

View file

@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn}
error={Boolean(errors.password)}
/>
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
{Boolean(loginError) && (
<FormErrorMessage message="Invalid email or password. Please try again." />
)}
</FormField>
<Button

View file

@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -37,9 +38,13 @@ export default function LoginPage() {
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
<Text
variant="caption"
tone="muted"
className="mt-5! block text-center text-(length:--text-xs)"
>
Having trouble? Contact your administrator.
</p>
</Text>
</AuthPageShell>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -120,7 +120,7 @@ export default function EventFormPage() {
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
{error && <Alert severity="error">Failed to load event</Alert>}
{Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ export default function NotificationsPage() {
</Typography>
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{error && <Alert severity="error">{error.message}</Alert>}
{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>
@ -70,14 +70,14 @@ export default function NotificationsPage() {
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt && (
{item.scheduledAt != null && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action && (
{item.action != null && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,12 +116,12 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
</DialogTitle>
<DialogContent>
<Stack spacing={2} className="mt-1">
{facetsError && (
{Boolean(facetsError) && (
<Alert severity="warning">
Company autocomplete unavailable. You can still type a company manually.
</Alert>
)}
{submitError && <Alert severity="error">{submitError}</Alert>}
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
{isLoadingFacets ? (
<CircularProgress size={24} />
) : (

View file

@ -264,7 +264,7 @@ export function VendorDetailDrawer({
<>
{mode === "view" ? (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
{submitError && <Alert severity="error">{submitError}</Alert>}
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip
@ -272,7 +272,7 @@ export function VendorDetailDrawer({
label={detail.isActive ? "Active" : "Inactive"}
color={detail.isActive ? "success" : "default"}
/>
{trades[0] && (
{Boolean(trades[0]) && (
<Chip
size="small"
variant="outlined"
@ -303,7 +303,7 @@ export function VendorDetailDrawer({
.filter(Boolean)
.join(", ")}
/>
{mapsUrl && (
{Boolean(mapsUrl) && (
<Link
href={mapsUrl}
target="_blank"
@ -338,7 +338,7 @@ export function VendorDetailDrawer({
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
</DetailSection>
{detail.notes && (
{Boolean(detail.notes) && (
<DetailSection title="Notes">
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{detail.notes}
@ -355,7 +355,7 @@ export function VendorDetailDrawer({
sx={{ px: 3, py: 3 }}
>
<Stack spacing={3}>
{submitError && <Alert severity="error">{submitError}</Alert>}
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Controller
control={control}
@ -400,7 +400,7 @@ export function VendorDetailDrawer({
>
{mode === "view" ? (
<>
{mapsUrl && (
{Boolean(mapsUrl) && (
<Tooltip title="Open in Google Maps">
<Button
component="a"

View file

@ -129,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>
@ -157,7 +157,7 @@ export default function VendorFormPage() {
showTotalJobs={isEdit}
/>
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
{isEdit && id != null && <VendorPortalTokenPanel vendorId={id} />}
<Stack
direction="row"

View file

@ -300,7 +300,7 @@ export default function VendorsListPage() {
</Stack>
)}
{error && (
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendors"}
</Alert>
@ -474,7 +474,7 @@ export default function VendorsListPage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "technician" : "technicians"}
{(appliedChips.length > 0 || debouncedSearch) && " · filtered"}
{(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
@ -535,19 +535,21 @@ export default function VendorsListPage() {
</Stack>
)}
{impactError && !isLoadingImpact && (
{Boolean(impactError) && !isLoadingImpact && (
<Alert severity="warning">
Could not verify linked work orders. Close and try again.
</Alert>
)}
{deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{deactivationImpact != null &&
!isLoadingImpact &&
!deactivationImpact.canDeactivate && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && (
{deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && (
<Box>
<Typography variant="subtitle2" className="font-semibold uppercase">
Open work orders ({deactivationImpact.openWorkOrders.length})
@ -571,7 +573,7 @@ export default function VendorsListPage() {
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
</Typography>
{wo.status && (
{Boolean(wo.status) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.status}
</Typography>
@ -583,7 +585,7 @@ export default function VendorsListPage() {
</Box>
)}
{deleteError && <Alert severity="error">{deleteError}</Alert>}
{Boolean(deleteError) && <Alert severity="error">{deleteError}</Alert>}
</Stack>
</DialogContent>
<DialogActions>

View file

@ -82,7 +82,7 @@ export default function VendorInsightsPage() {
</Stack>
</Paper>
{isLoading && <CircularProgress aria-label="Loading vendor insights" />}
{error && <Alert severity="error">{error.message}</Alert>}
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Alert severity="info">No data matches the selected filters.</Alert>
)}

View file

@ -9,19 +9,107 @@ import {
Paper,
Stack,
TextField,
Typography,
} 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>("");
@ -46,13 +134,10 @@ export default function VendorPreferencesPage() {
}
}, [isDirty, serverPreferences]);
const vendorOptions = useMemo(
() =>
vendors.filter(
(vendor) => !draft.some((preference) => preference.vendorId === Number(vendor.id)),
),
[draft, vendors],
);
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: () =>
@ -69,7 +154,7 @@ export default function VendorPreferencesPage() {
setIsDirty(false);
toast.success("Preferred vendors updated");
},
onError: (mutationError: Error) => toast.error(mutationError.message),
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
});
const move = (index: number, offset: number) => {
@ -116,13 +201,13 @@ export default function VendorPreferencesPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
<Text variant="title" as="h1">
Preferred vendors by site
</Typography>
<Typography color="text.secondary">
</Text>
<Text variant="description" tone="muted">
Preferred vendors rank first, but availability, conflict, trade, active-state, and
permission checks still apply.
</Typography>
</Text>
</Box>
<Paper variant="outlined" className="p-4">
<Autocomplete
@ -133,65 +218,30 @@ export default function VendorPreferencesPage() {
renderInput={(params) => <TextField {...params} label="Site" />}
/>
</Paper>
{error && <Alert severity="error">{error.message}</Alert>}
<Text variant="error" when={Boolean(error)}>
Unable to load preferred vendors. Please try again.
</Text>
{locationId !== "" && (
<>
<Paper variant="outlined" className="p-4">
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
label="Trade scope (optional)"
value={trade}
onChange={(event) => setTrade(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) => setVendorId(option ? Number(option.id) : null)}
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
className="flex-1"
/>
<Button variant="contained" onClick={add} disabled={vendorId == null}>
Add
</Button>
</Stack>
</Paper>
<AddVendorPreferenceControls
trade={trade}
onTradeChange={setTrade}
vendorOptions={vendorOptions}
vendors={vendors}
vendorId={vendorId}
onVendorChange={setVendorId}
onAdd={add}
/>
<Stack spacing={1}>
{draft.map((item, index) => (
<Paper
<PreferenceRow
key={`${item.vendorId}-${item.trade ?? "all"}`}
variant="outlined"
className="p-3"
>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography className="flex-1">
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
</Typography>
{item.isStale && <Alert severity="warning">Stale preference</Alert>}
<IconButton
aria-label={`Move ${item.vendorName} up`}
onClick={() => move(index, -1)}
disabled={index === 0}
>
<ArrowUp size={18} />
</IconButton>
<IconButton
aria-label={`Move ${item.vendorName} down`}
onClick={() => move(index, 1)}
disabled={index === draft.length - 1}
>
<ArrowDown size={18} />
</IconButton>
<IconButton
aria-label={`Remove ${item.vendorName}`}
onClick={() => remove(index)}
>
<Trash2 size={18} />
</IconButton>
</Stack>
</Paper>
item={item}
index={index}
total={draft.length}
onMove={move}
onRemove={remove}
/>
))}
</Stack>
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>

View file

@ -226,12 +226,12 @@ export default function WorkOrderViewPage() {
if (!commentText.trim()) {
return;
}
if (activeTab !== "customer" && activeTab !== "internal") {
return;
}
commentMutation.mutate(
{
text: commentText.trim(),
commentType: activeTab === "customer" ? "customer" : "internal",
},
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
@ -658,7 +658,7 @@ export default function WorkOrderViewPage() {
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId && (
{dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
@ -680,7 +680,7 @@ export default function WorkOrderViewPage() {
<Typography variant="body2" color="text.secondary">
{vendorEditor?.vendorName}
</Typography>
{vendorEditorError && <Alert severity="error">{vendorEditorError}</Alert>}
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
{vendorEditorLoading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />

View file

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

View file

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

View file

@ -0,0 +1,168 @@
import { useMemo } from "react";
import { Popover } from "@mui/material";
import { ChevronDown, UserX, Users } from "lucide-react";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
MOCK_DISPATCHER_USERS,
getDispatcherAvatarColor,
} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type DispatcherFilterProps = {
selectedIds: Set<string>;
onChange: (ids: Set<string>) => void;
users: WorkOrderUser[];
currentUserId?: string | number;
};
function userInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
return (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }}
>
{userInitials(name)}
</span>
);
}
export function DispatcherFilter({
selectedIds,
onChange,
users,
currentUserId,
}: DispatcherFilterProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
const allSelected = selectedIds.size === 0;
const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId);
const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED);
const label = useMemo(() => {
if (allSelected) return "All dispatchers";
if (meOnly) return "My WOs";
if (unassignedOnly) return "Unassigned";
return `${selectedIds.size} selected`;
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
const dispatchers = useMemo(() => {
const byId = new Map<string, WorkOrderUser>();
for (const user of [...MOCK_DISPATCHER_USERS, ...users]) {
byId.set(String(user.id), user);
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [users]);
const toggle = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onChange(next);
};
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
style={
!allSelected
? {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--color-primary-tint)",
borderColor: "#bfe2f5",
color: "var(--primary)",
}
: {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--card)",
borderColor: "var(--border)",
color: "var(--foreground)",
}
}
aria-haspopup="listbox"
>
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
},
}}
>
<CheckboxMenuItem
checked={allSelected}
label="All dispatchers"
onClick={() => onChange(new Set())}
/>
{meId != null && (
<CheckboxMenuItem
checked={meOnly}
label="My WOs only"
labelClassName="font-medium text-primary"
onClick={() => onChange(new Set([meId]))}
/>
)}
<CheckboxMenuItem
checked={selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED)}
label="Unassigned"
left={<UserX size={14} className="shrink-0 text-muted-foreground" />}
className="mb-1 pb-2"
style={{ borderBottom: "1px solid var(--color-border)" }}
onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)}
/>
{dispatchers.map((user) => {
const id = String(user.id);
const isMe = meId === id;
return (
<CheckboxMenuItem
key={id}
checked={selectedIds.has(id)}
label={user.name}
left={<DispatcherAvatar name={user.name} color={getDispatcherAvatarColor(user)} />}
right={
isMe ? <span className="text-[10px] text-muted-foreground">me</span> : undefined
}
onClick={() => toggle(id)}
/>
);
})}
</Popover>
</>
);
}

View file

@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
import { NavPillButton } from "@/components/ui/nav-pill-button";
import {
buildWeekWindow,
isFutureWeek,
isLiveWeek,
} from "@/domain/work-orders/utils/list-week-utils";
type WeekNavProps = {
weekMonday: string;
onChange: (mondayIso: string) => void;
};
const VISIBLE_COUNT = 3;
const WINDOW_BEFORE = 12;
const WINDOW_AFTER = 1;
function WeekLiveBadge() {
return (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
const weeks = useMemo(
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
[weekMonday],
);
const activeIndex = useMemo(
() => weeks.findIndex((week) => week.mondayIso === weekMonday),
[weeks, weekMonday],
);
const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
useEffect(() => {
if (activeIndex < 0) return;
const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
setOffset((current) => {
if (activeIndex < current) return Math.min(activeIndex, maxOffset);
if (activeIndex > current + VISIBLE_COUNT - 1) {
return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
}
return current;
});
}, [activeIndex, weeks.length]);
const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
const canGoBack = offset > 0;
const canGoForward = offset + VISIBLE_COUNT < weeks.length;
return (
<div className="flex shrink-0 items-center gap-1.5">
<NavArrowButton
disabled={!canGoBack}
onClick={() => setOffset((current) => current - 1)}
aria-label="Previous weeks"
>
<ChevronLeft size={15} />
</NavArrowButton>
{visible.map((week) => {
const isActive = weekMonday === week.mondayIso;
const isDisabled = isFutureWeek(week.mondayIso);
const showLiveBadge = isLiveWeek(week.mondayIso);
return (
<NavPillButton
key={week.mondayIso}
active={isActive}
disabled={isDisabled}
onClick={() => onChange(week.mondayIso)}
>
{week.label}
{showLiveBadge && <WeekLiveBadge />}
</NavPillButton>
);
})}
<NavArrowButton
disabled={!canGoForward}
onClick={() => setOffset((current) => current + 1)}
aria-label="Next weeks"
>
<ChevronRight size={15} />
</NavArrowButton>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control";
import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
type WoTypeSegmentProps = {
value: WorkOrderTypeFilter;
onChange: (value: WorkOrderTypeFilter) => void;
};
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
return (
<SegmentGroup>
{OPTIONS.map((option) => (
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
{option}
</SegmentButton>
))}
</SegmentGroup>
);
}

View file

@ -0,0 +1,48 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav";
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
type WorkOrdersFilterBarProps = {
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void;
typeFilter: WorkOrderTypeFilter;
onTypeChange: (value: WorkOrderTypeFilter) => void;
users: WorkOrderUser[];
currentUserId?: string | number;
filteredCount: number;
scopeTotalCount: number;
};
export function WorkOrdersFilterBar({
weekMonday,
onWeekChange,
dispatcherIds,
onDispatcherChange,
typeFilter,
onTypeChange,
users,
currentUserId,
filteredCount,
scopeTotalCount,
}: WorkOrdersFilterBarProps) {
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
return (
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
currentUserId={currentUserId}
/>
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
<div className="flex-1" />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
</div>
);
}

View file

@ -0,0 +1,147 @@
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,
formatApptRange,
formatApptTimeValue,
parseApptTime,
TIME_SLOTS,
} from "@/domain/work-orders/utils/wizard-date-utils";
import { HoverChevron } from "./hover-chevron";
type ApptTimeCellProps = {
apptTime: string;
onSave: (value: string) => void;
};
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
const [end, setEnd] = useState(() => parseApptTime(apptTime).end);
useEffect(() => {
const p = parseApptTime(apptTime);
setStart(p.start);
setEnd(p.end);
}, [apptTime]);
const parsed = parseApptTime(apptTime);
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
const slotStyle = (active: boolean): React.CSSProperties => ({
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
});
const done = () => {
onSave(formatApptTimeValue(start, end));
setAnchorEl(null);
};
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
{display && display !== "—" ? (
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
{display}
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
>
<div className="grid grid-cols-2 border-b border-border">
<div className="border-r border-border">
<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
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
onClick={() => {
setStart(t);
if (end && end <= t) {
setEnd("");
}
}}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(start === t)}
>
{fmt12(t)}
</button>
))}
</div>
</div>
<div>
<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>
</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
key={t}
type="button"
onClick={() => setEnd(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(end === t)}
>
{fmt12(t)}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-[var(--color-text-muted)]">
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
</span>
<Button size="small" variant="contained" onClick={done} disabled={!start}>
Done
</Button>
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { Check, Users } from "lucide-react";
import { Popover } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { DispatcherPill } from "./dispatcher-pill";
import { HoverChevron } from "./hover-chevron";
type AssignedToCellProps = {
dispatcherId: string;
users: WorkOrderUser[];
highlight?: boolean;
onSave: (id: string, name: string) => void;
};
export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const selected = users.find((d) => String(d.id) === dispatcherId);
const filtered = search
? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase()))
: users;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 6px",
margin: "0 -6px",
}
: undefined
}
>
{selected ? (
<DispatcherPill user={selected} />
) : (
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<Users
size={13}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
Select …
</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setSearch("");
}}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }}
>
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search dispatcher…"
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>
<div className="max-h-52 overflow-y-auto">
{filtered.map((d) => (
<button
key={String(d.id)}
type="button"
onClick={() => {
onSave(String(d.id), d.name);
setAnchorEl(null);
setSearch("");
}}
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
>
<DispatcherPill user={d} />
{dispatcherId === String(d.id) && (
<Check size={10} className="shrink-0 text-[var(--color-primary)]" />
)}
</button>
))}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,72 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void;
onOpenMedia: () => void;
};
export function CompDocCell({
status,
woType,
mediaCount = 0,
onOpen,
onOpenMedia,
}: CompDocCellProps) {
if (isMediaWO(woType)) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenMedia();
}}
title="Photos & videos"
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold text-[var(--color-primary)] transition-opacity hover:opacity-80"
>
<ImageIcon size={15} className="shrink-0 text-[var(--color-primary)]" />
{mediaCount > 0 ? `${mediaCount} file${mediaCount !== 1 ? "s" : ""}` : "Media"}
</button>
);
}
if (!onOpen) {
return null;
}
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
const Icon = notRequired ? Ban : FileText;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
title={
generated
? "View completion document"
: notRequired
? "Completion document — not required"
: "Completion document — draft"
}
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold transition-opacity hover:opacity-80"
style={{ color }}
>
<Icon size={15} className="shrink-0" style={{ color }} />
{generated ? "View Doc" : notRequired ? "N/N" : "Draft"}
</button>
);
}

View file

@ -0,0 +1,139 @@
import { useState } from "react";
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
type DateCellProps = {
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
allowWeekMode?: boolean;
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
};
export function DateCell({
value,
q,
highlight,
onSave,
allowWeekMode,
targetWeek = "",
onSaveWeek,
defaultMode,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
);
const showWeek = allowWeekMode && !value && !!targetWeek;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showWeek ? (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
) : (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
>
{Boolean(allowWeekMode) && (
<div className="mb-2.5 flex items-center gap-1.5">
{(["week", "date"] as const).map((m) => {
const active = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
className="rounded-full border px-3 text-[11.5px] font-medium"
style={{
height: 26,
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
</div>
)}
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
selected={!value ? targetWeek : ""}
onPick={(mon) => {
onSave("");
onSaveWeek?.(mon);
setAnchorEl(null);
}}
/>
) : (
<MiniCalendar
value={value}
onChange={(d) => {
onSave(d);
setAnchorEl(null);
}}
/>
)}
</Popover>
</>
);
}

View file

@ -0,0 +1,40 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
import { HighlightText } from "./highlight-text";
type DispatcherPillProps = {
user: WorkOrderUser;
q?: string;
};
export function DispatcherPill({ user, q }: DispatcherPillProps) {
const color = getDispatcherAvatarColor(user);
return (
<span
className="inline-flex max-w-full items-center truncate whitespace-nowrap leading-none"
style={{
height: 26,
padding: "0 11px",
borderRadius: 9999,
border: `1.5px solid ${color}`,
background: "var(--card)",
color,
fontSize: 12.5,
fontWeight: 600,
}}
>
<HighlightText text={user.name} q={q} />
</span>
);
}

View file

@ -0,0 +1,212 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) {
return null;
}
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
}}
>
{count}
</span>
);
}
export function EditableWONumberCell({
value,
rescheduleCount,
carriedOver = 0,
q,
currentId,
allOrders,
onSave,
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => {
if (!editing) {
setDraft(value);
}
}, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => {
if (draft === value) {
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
return;
}
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
setEditing(false);
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
const handleBlur = () => {
if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
}
setEditing(false);
};
const invalid = editing && draft.length > 0 && draft.length !== 11;
return (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={handleBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
) : (
<span
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>
);
}

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { Flag, X } from "lucide-react";
import { Popover } from "@mui/material";
const FLAG_COLORS: { name: string; value: string }[] = [
{ name: "Red", value: "#EF4444" },
{ name: "Orange", value: "#F97316" },
{ name: "Amber", value: "#F59E0B" },
{ name: "Yellow", value: "#EAB308" },
{ name: "Green", value: "#22C55E" },
{ name: "Teal", value: "#14B8A6" },
{ name: "Blue", value: "#3B82F6" },
{ name: "Purple", value: "#8B5CF6" },
];
type FlagCellProps = {
color?: string;
disabled?: boolean;
onChange: (color: string | null) => void;
};
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (disabled) {
return (
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
<Flag size={14} />
</span>
);
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
title={color ? "Flagged — click to change" : "Add color flag"}
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
>
<Flag
size={14}
strokeWidth={color ? 1.5 : 1.75}
style={{
color: color ?? "var(--color-text-muted)",
fill: color ?? "transparent",
}}
/>
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
>
<div className="grid grid-cols-4 gap-1.5">
{FLAG_COLORS.map((c) => (
<button
key={c.value}
type="button"
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
>
<span
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow:
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
}}
/>
</button>
))}
</div>
<button
type="button"
onClick={() => {
onChange(null);
setAnchorEl(null);
}}
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
>
<X size={12} /> Clear flag
</button>
</Popover>
</>
);
}

View file

@ -0,0 +1,26 @@
type HighlightTextProps = {
text: string;
q?: string;
};
export function HighlightText({ text, q }: HighlightTextProps) {
if (!q?.trim() || !text) {
return <>{text}</>;
}
const query = q.trim();
const lower = text.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) {
return <>{text}</>;
}
const before = text.slice(0, idx);
const match = text.slice(idx, idx + query.length);
const after = text.slice(idx + query.length);
return (
<>
{before}
<mark className="rounded-sm bg-[var(--color-primary-tint)] px-0.5 text-inherit">{match}</mark>
{after}
</>
);
}

View file

@ -0,0 +1,11 @@
import { ChevronDown } from "lucide-react";
export function HoverChevron() {
return (
<ChevronDown
size={14}
className="ml-auto shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
);
}

View file

@ -0,0 +1,120 @@
import { useState } from "react";
import { Check } from "lucide-react";
import { Popover } from "@mui/material";
import { PmTypeIcon } from "../pm-type-icons";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
type PMTypeCellProps = {
value: string;
pmTypes: string[];
q?: string;
onSave: (v: string) => void;
};
export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const filtered = search
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: pmTypes;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
<span
className="min-w-0 truncate"
style={{
fontSize: 13,
color: "var(--color-text-primary)",
}}
>
<HighlightText text={value} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setSearch("");
}}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
>
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search PM type…"
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>
<div className="max-h-52 overflow-y-auto">
{filtered.map((pm) => (
<button
key={pm}
type="button"
onClick={() => {
onSave(pm);
setAnchorEl(null);
setSearch("");
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
<span
className="flex-1 truncate text-left text-xs"
style={{ color: "var(--color-text-primary)" }}
>
{pm}
</span>
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
</button>
))}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,79 @@
import { useState } from "react";
import { MapPin } from "lucide-react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { SiteDialog, type SitePatch } from "./site-dialog";
type SiteCellProps = {
value: string;
location: string;
locationId: string | number;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
q?: string;
onSave: (patch: SitePatch) => void;
};
export function SiteCell({
value,
location,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
q,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full min-w-0 items-center gap-1.5 transition-opacity hover:opacity-80"
>
{value ? (
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
<MapPin size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span
className="shrink-0 font-mono text-[12.5px] font-bold tracking-wide"
style={{ color: "var(--foreground)" }}
>
<HighlightText text={value} q={q} />
</span>
{Boolean(location) && (
<>
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
<span className="truncate text-xs text-[var(--muted-foreground)]">
<HighlightText text={location} q={q} />
</span>
</>
)}
</span>
) : (
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<SiteDialog
open={open}
onOpenChange={setOpen}
value={value}
locationId={locationId}
pocName={pocName}
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
onSave={onSave}
/>
</>
);
}

View file

@ -0,0 +1,277 @@
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} 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";
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type SiteDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
onSave: (patch: SitePatch) => void;
};
function FieldLabel({ children }: { children: ReactNode }) {
return (
<label
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--color-text-secondary)" }}
>
{children}
</label>
);
}
function extractSiteCode(name: string): string {
const dash = name.indexOf(" — ");
if (dash > 0) {
return name.slice(0, dash).trim();
}
return name.slice(0, 8).toUpperCase();
}
function extractLocationLabel(name: string): string {
const dash = name.indexOf(" — ");
if (dash > 0) {
return name.slice(dash + 3).trim();
}
return name;
}
function buildMapsUrl(location: Location): string {
const parts = [location.address, location.city, location.state, location.zipCode]
.filter(Boolean)
.join(", ");
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
}
export function SiteDialog({
open,
onOpenChange,
value,
locationId,
pocName = "",
pocPhone = "",
pocNotes = "",
sites,
onSave,
}: SiteDialogProps) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(String(locationId || ""));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
useEffect(() => {
if (open) {
setCode(value);
setLocId(String(locationId || ""));
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setPocFilledFor(String(locationId || ""));
}
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn(locationDetail.contact ?? "");
setPp(locationDetail.phone ?? "");
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const selected = sites.find((s) => String(s.id) === locId);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
setLocId(id);
if (next) {
setCode(extractSiteCode(next.name));
}
if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Site
</DialogTitle>
<Text as="p" className="sr-only">
Select a site and edit its point of contact
</Text>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect
value={locId}
placeholder="Select site …"
icon={
<MapPin
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search site code or city…"
options={sites.map((site) => ({
key: String(site.id),
label: site.name,
}))}
onPick={handlePick}
/>
</div>
{locationDetail != null && Boolean(locId) && (
<div
className="space-y-1.5 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<Detail
icon={MapPin}
text={
[
locationDetail.address,
locationDetail.city,
locationDetail.state,
locationDetail.zipCode,
]
.filter(Boolean)
.join(", ") || "—"
}
/>
{locationDetail.phone != null && <Detail icon={Phone} text={locationDetail.phone} />}
{locationDetail.contactEmail != null && (
<Detail icon={Mail} text={locationDetail.contactEmail} />
)}
{Boolean(locationDetail.address) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
</div>
)}
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<Text
as="p"
className="mb-2"
style={{ fontSize: 11, color: "var(--color-text-muted)" }}
>
Auto-filled from records — edit if outdated
</Text>
<div className="space-y-2">
<input
value={pn}
onChange={(e) => setPn(e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={pp}
onChange={(e) => setPp(e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
site: code || (selected ? extractSiteCode(selected.name) : ""),
location: selected ? extractLocationLabel(selected.name) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
}}
>
Save
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,159 @@
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,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
type StatusCellProps = {
wo: WorkOrderTableRow;
onChangeStatus: (status: WizardWOStatus) => void;
onOpenSlideOver: () => void;
onPastDueAttempt?: () => void;
};
export function StatusCell({
wo,
onChangeStatus,
onOpenSlideOver,
onPastDueAttempt,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (wo.isPastDue) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
}}
>
<div
className="flex items-start gap-2 p-3"
style={{ background: "var(--status-pending-bg)" }}
>
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<div>
<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.
</Text>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenSlideOver();
setAnchorEl(null);
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
</button>
</div>
</div>
</Popover>
</>
);
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{wo.status ? (
<WoTableStatusLabel status={wo.status} />
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{ALL_WIZARD_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => {
onChangeStatus(status);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
>
<WoTableStatusLabel status={status} />
{wo.status === status && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
))}
</Popover>
</>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Check } from "lucide-react";
import { Popover } from "@mui/material";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { HoverChevron } from "./hover-chevron";
import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = {
value: WOType | "";
q?: string;
onSave: (v: WOType) => void;
};
export function TypeCell({ value, q, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{value ? (
<WoTableTypeLabel type={value} q={q} />
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{WO_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => {
onSave(t);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button>
))}
</Popover>
</>
);
}

View file

@ -0,0 +1,57 @@
import { useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = {
company: string;
tech: string;
techPhone?: string;
vendors: VendorDropdownItem[];
q?: string;
onSave: (patch: VendorPatch) => void;
};
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
const [open, setOpen] = useState(false);
const hasAny = !!(tech || company);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center gap-1.5 text-left transition-opacity hover:opacity-80"
>
{hasAny ? (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
</div>
{Boolean(tech && company) && (
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
<HighlightText text={company} q={q} />
</div>
)}
</div>
) : (
<span className="flex-1 text-[13px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<VendorDialog
open={open}
onOpenChange={setOpen}
company={company}
tech={tech}
techPhone={techPhone}
vendors={vendors}
onSave={onSave}
/>
</>
);
}

View file

@ -0,0 +1,271 @@
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import { ExternalLink, Mail, Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} 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";
export type VendorPatch = {
company: string;
tech: string;
techPhone: string;
};
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
company: string;
tech: string;
techPhone?: string;
vendors: VendorDropdownItem[];
onSave: (patch: VendorPatch) => void;
};
type TechnicianOption = {
key: string;
label: string;
name: string;
company: string;
phone: string;
vendor?: VendorDropdownItem;
};
/** Known tech ↔ company pairs from seeded table mock data. */
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
};
function FieldLabel({ children }: { children: ReactNode }) {
return (
<label
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--color-text-secondary)" }}
>
{children}
</label>
);
}
function buildTechnicianOptions(
vendors: VendorDropdownItem[],
tech: string,
company: string,
): TechnicianOption[] {
const options = new Map<string, TechnicianOption>();
const add = (option: TechnicianOption) => {
if (!option.key) {
return;
}
options.set(option.key, option);
};
for (const vendor of vendors) {
const mockTech = MOCK_TECH_BY_COMPANY[vendor.companyName];
if (mockTech) {
add({
key: mockTech.name,
label: `${mockTech.name} · ${vendor.companyName}`,
name: mockTech.name,
company: vendor.companyName,
phone: mockTech.phone,
vendor,
});
continue;
}
add({
key: vendor.companyName,
label: vendor.companyName,
name: vendor.companyName,
company: vendor.companyName,
phone: "",
vendor,
});
}
if (tech) {
const vendor = vendors.find((v) => v.companyName === company);
add({
key: tech,
label: company && company !== tech ? `${tech} · ${company}` : tech,
name: tech,
company: company || tech,
phone: "",
vendor,
});
}
return Array.from(options.values());
}
function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
return options.find((o) => o.name === name)?.phone ?? fallback;
}
export function VendorDialog({
open,
onOpenChange,
company,
tech,
techPhone = "",
vendors,
onSave,
}: VendorDialogProps) {
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
);
useEffect(() => {
if (open) {
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setNotes("");
}
}, [open, tech, techPhone, technicianOptions]);
const selected = technicianOptions.find((o) => o.name === t);
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const companyEmail = selectedVendor?.email?.trim() || "";
const handlePick = (name: string) => {
setT(name);
setPhone(phoneForOption(name, technicianOptions));
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Technician
</DialogTitle>
<Text as="p" className="sr-only">
Search and select the technician
</Text>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={t}
placeholder="Search technician …"
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={handlePick}
/>
</div>
{Boolean(t) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(t) && (
<div>
<FieldLabel>Company</FieldLabel>
<div
className="space-y-1.5 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{resolvedCompany || "—"}
</div>
{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"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
</div>
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
onOpenChange(false);
}}
>
Save
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,25 @@
import { getWoTableStatusStyle } from "../wo-table-status-style";
import { HighlightText } from "./highlight-text";
type WoTableStatusLabelProps = {
status: string;
q?: string;
};
export function WoTableStatusLabel({ status, q }: WoTableStatusLabelProps) {
const style = getWoTableStatusStyle(status);
const label = status || "—";
return (
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap leading-none"
style={{ fontSize: 12.5, fontWeight: 600, color: style.text }}
>
<span
className="shrink-0 rounded-full"
style={{ width: 8, height: 8, background: style.dot }}
/>
<HighlightText text={label} q={q} />
</span>
);
}

View file

@ -0,0 +1,19 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { getWoTableTypeColor } from "../wo-table-type-style";
import { HighlightText } from "./highlight-text";
type WoTableTypeLabelProps = {
type: WOType;
q?: string;
};
export function WoTableTypeLabel({ type, q }: WoTableTypeLabelProps) {
return (
<span
className="inline-flex items-center whitespace-nowrap leading-none"
style={{ fontSize: 12.5, fontWeight: 700, color: getWoTableTypeColor(type) }}
>
<HighlightText text={type} q={q} />
</span>
);
}

View file

@ -0,0 +1,45 @@
import { createElement, type ComponentProps } from "react";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Droplets,
Filter,
Gauge,
Lightbulb,
Lock,
Shield,
ShieldCheck,
Thermometer,
Video,
Waves,
Wind,
Wrench,
Zap,
} from "lucide-react";
export const PM_TYPE_ICONS: Record<string, LucideIcon> = {
"Scrubber Pit": Filter,
"Backflow Preventers": Shield,
"Domestic Backflow": ShieldCheck,
"Tank / Water Heater": Thermometer,
"Pressure Reducing Valve": Gauge,
"Pumping Systems": Waves,
"HVAC Inspection": Wind,
Electrical: Zap,
Plumbing: Wrench,
"Door / Hardware": Lock,
"Floor Drain Backing Up": Droplets,
"Parking Lot Lighting": Lightbulb,
"Mens Restroom Camera Line": Video,
"Assess Window Leaks": AlertTriangle,
};
export function getPmTypeIcon(pmType: string): LucideIcon {
return PM_TYPE_ICONS[pmType] ?? Wrench;
}
type PmTypeIconProps = ComponentProps<LucideIcon> & { pmType: string };
export function PmTypeIcon({ pmType, ...props }: PmTypeIconProps) {
return createElement(getPmTypeIcon(pmType), props);
}

View file

@ -0,0 +1,19 @@
export const WO_TABLE_COLS = [
{ key: "grip", label: "", w: 28 },
{ key: "flag", label: "", w: 28 },
{ key: "site", label: "SITE", w: 168 },
{ key: "wo", label: "WO", w: 120 },
{ key: "type", label: "TYPE OF WO", w: 108 },
{ key: "assigned", label: "ASSIGNED TO", w: 136 },
{ key: "sched", label: "SCHEDULE ON", w: 112 },
{ key: "due", label: "DUE DATE", w: 112 },
{ key: "pm", label: "SERVICE", w: 168 },
{ key: "vendor", label: "VENDOR", w: 184 },
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1504;
export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -0,0 +1,31 @@
import { WO_TABLE_COLS } from "./wo-table-cols";
export function WoTableHead() {
return (
<thead className="sticky top-0 z-10">
<tr>
{WO_TABLE_COLS.map((col) => (
<th
key={col.key}
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
);
}

View file

@ -0,0 +1,288 @@
import { Check, Flag, X } from "lucide-react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
draftDayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void;
onCancel: () => void;
};
export function WoTableInlineRow({
draft,
draftDayKey,
users,
sites,
vendors,
pmTypes,
allRows,
onChange,
onSave,
onCancel,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn;
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
const draftRow = {
id: "draft",
woNumber: draft.woNumber,
status: draft.status,
isPastDue: false,
type: draft.type,
rescheduleCount: 0,
carriedOver: 0,
docStatus: "" as const,
mediaCount: 0,
site: draft.site,
location: draft.location,
dispatcherId: draft.dispatcherId,
scheduledOn: draft.scheduledOn,
dueDate: draft.dueDate,
pm: draft.pm,
company: draft.company,
tech: draft.tech,
targetWeek: draft.targetWeek,
};
return (
<tr
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
}}
>
<td style={{ padding: "0 0 0 6px" }} />
<td style={{ padding: "0 2px 0 8px" }}>
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(p) =>
patch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px" }}>
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => patch({ woNumber: v })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => patch({ type: t })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell
dispatcherId={draft.dispatcherId}
users={users}
highlight={!draft.dispatcherId}
onSave={(id) => {
patch({ dispatcherId: id, status: draft.status || "Incomplete" });
}}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => patch({ dueDate: date })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<PMTypeCell value={draft.pm} pmTypes={pmTypes} onSave={(pm) => patch({ pm })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(p) => patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<ApptTimeCell
apptTime={
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
}
onSave={(v) => {
const [start, end] = v.split(" – ");
patch({ apptStart: start ?? "", apptEnd: end ?? "" });
}}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<StatusCell
wo={draftRow as never}
onChangeStatus={(status) => patch({ status })}
onOpenSlideOver={() => {}}
/>
</td>
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
<td style={{ padding: "0 4px" }}>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={onSave}
disabled={!canSave}
title={canSave ? "Save" : "Schedule On is required"}
className="rounded p-1 transition-colors disabled:opacity-30"
style={{ color: "var(--color-primary)" }}
onMouseEnter={(e) => {
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background =
"var(--color-primary-tint)";
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<Check size={13} />
</button>
<button
type="button"
onClick={onCancel}
title="Cancel"
className="rounded p-1 transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<X size={13} />
</button>
</div>
</td>
</tr>
);
}

View file

@ -0,0 +1,67 @@
import { Eye, Pencil } from "lucide-react";
type WoTableRowActionsProps = {
onView: () => void;
onEdit?: () => void;
};
export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) {
const mk = (
Icon: React.ElementType,
title: string,
onClick: () => void,
hoverBg: string,
hoverColor: string,
) => (
<button
type="button"
title={title}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="flex items-center justify-center transition-colors"
style={{
width: 28,
height: 28,
borderRadius: 6,
color: "var(--muted-foreground)",
background: "transparent",
}}
onMouseEnter={(e) => {
const b = e.currentTarget as HTMLButtonElement;
b.style.background = hoverBg;
b.style.color = hoverColor;
}}
onMouseLeave={(e) => {
const b = e.currentTarget as HTMLButtonElement;
b.style.background = "transparent";
b.style.color = "var(--muted-foreground)";
}}
>
<Icon size={15} />
</button>
);
return (
<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 != null && mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")}
</div>
);
}

View file

@ -0,0 +1,320 @@
import { GripVertical } from "lucide-react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import {
applySchedulePatch,
maybeAutoSchedulePatch,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { FlagCell } from "./cells/flag-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableRowActions } from "./wo-table-row-actions";
export type WoTableRowHandlers = {
onRowClick: (row: WorkOrderTableRow) => void;
onView: (row: WorkOrderTableRow) => void;
onEdit: (row: WorkOrderTableRow) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenSlideOver: (row: WorkOrderTableRow) => void;
onPastDueAttempt?: (id: string | number) => void;
onReorder: (dragId: string | number, overId: string | number) => void;
onDragStart: (id: string | number) => void;
onDragOver: (id: string | number) => void;
onDrop: () => void;
onDragEnd: () => void;
setFlag: (id: string | number, color: string | null) => void;
};
type WoTableRowProps = {
row: WorkOrderTableRow;
allRows: WorkOrderTableRow[];
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
search?: string;
forceScheduleHighlight?: boolean;
flagColor?: string;
isSelected: boolean;
isFlashing: boolean;
dragViz: DragVizState;
handlers: WoTableRowHandlers;
};
export function WoTableRow({
row,
allRows,
users,
sites,
vendors,
pmTypes,
search,
forceScheduleHighlight,
flagColor,
isSelected,
isFlashing,
dragViz,
handlers,
}: WoTableRowProps) {
const isDragging = dragViz.dragId === row.id;
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
const isCanceled = (row.status as string) === "Canceled";
let firstCellShadow = "none";
if (row.type === "Emergency") {
firstCellShadow = "inset 3px 0 0 var(--destructive)";
} else if (row.isPastDue) {
firstCellShadow = "inset 3px 0 0 var(--warning)";
}
const patchAndMaybeAuto = (patch: Partial<WorkOrderTableRow>) => {
if (isCanceled) return;
const merged = { ...row, ...patch };
const auto = maybeAutoSchedulePatch(merged);
handlers.onPatch(row.id, { ...patch, ...auto });
};
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
if (isCanceled) return;
handlers.onPatch(row.id, patch);
};
/** Block mutation controls on canceled rows while keeping row/View clickable. */
const mutationCellStyle = isCanceled ? ({ pointerEvents: "none" } as const) : undefined;
return (
<tr
id={`wo-row-${row.id}`}
draggable={!isCanceled}
title={isCanceled ? "This work order has been cancelled" : undefined}
onClick={() => handlers.onRowClick(row)}
onDragStart={() => {
if (isCanceled) return;
handlers.onDragStart(row.id);
}}
onDragOver={(e) => {
if (isCanceled) return;
e.preventDefault();
handlers.onDragOver(row.id);
}}
onDrop={(e) => {
if (isCanceled) return;
e.preventDefault();
handlers.onDrop();
}}
onDragEnd={handlers.onDragEnd}
className="group/row cursor-pointer transition-colors"
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--border)",
borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined,
opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1,
background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)",
outline: isFlashing ? "2px solid var(--color-primary)" : "none",
outlineOffset: isFlashing ? "-2px" : undefined,
borderRadius: isFlashing ? 4 : undefined,
transition: "background 1.8s ease-out, outline 1.8s ease-out",
}}
onMouseEnter={(e) => {
if (!isSelected && !isFlashing) {
(e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)";
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isFlashing) {
(e.currentTarget as HTMLTableRowElement).style.background = "var(--card)";
}
}}
>
<td
style={{ padding: "0 0 0 6px", boxShadow: firstCellShadow, ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<span
className="flex items-center justify-center"
style={{
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
}}
>
<GripVertical size={14} />
</span>
</td>
<td
style={{ padding: "0 2px 0 8px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<FlagCell
color={flagColor}
disabled={isCanceled}
onChange={(c) => handlers.setFlag(row.id, c)}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<SiteCell
value={row.site}
location={row.location}
locationId={row.locationId}
pocName={row.pocName}
pocPhone={row.pocPhone}
pocNotes={row.pocNotes}
sites={sites}
q={search}
onSave={(p) =>
patchRow({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px", whiteSpace: "nowrap", ...mutationCellStyle }}>
<EditableWONumberCell
value={row.woNumber}
rescheduleCount={row.rescheduleCount}
carriedOver={row.carriedOver}
q={search}
currentId={row.id}
allOrders={allRows}
onSave={(v) => patchRow({ woNumber: v })}
onDuplicateFound={handlers.onDuplicateFound}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<TypeCell value={row.type} q={search} onSave={(t) => patchRow({ type: t })} />
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<AssignedToCell
dispatcherId={row.dispatcherId}
users={users}
highlight={!row.dispatcherId}
onSave={(id, name) => patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => patchAndMaybeAuto(applySchedulePatch(date, false))}
onSaveWeek={(week) =>
patchAndMaybeAuto({
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
dayGroup: "",
dayLabel: "",
})
}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell value={row.dueDate} q={search} onSave={(date) => patchRow({ dueDate: date })} />
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<PMTypeCell value={row.pm} pmTypes={pmTypes} q={search} onSave={(pm) => patchRow({ pm })} />
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<VendorCell
company={row.company}
tech={row.tech}
techPhone={row.techPhone}
vendors={vendors}
q={search}
onSave={(p) =>
patchRow({
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<ApptTimeCell apptTime={row.apptTime} onSave={(v) => patchRow({ apptTime: v })} />
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<StatusCell
wo={row}
onChangeStatus={(status) => patchRow({ status })}
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<CompDocCell
status={row.docStatus}
woType={row.type}
mediaCount={row.mediaCount}
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
<WoTableRowActions
onView={() => handlers.onView(row)}
onEdit={isCanceled ? undefined : () => handlers.onEdit(row)}
/>
</td>
</tr>
);
}

View file

@ -0,0 +1,144 @@
import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react";
import { WO_TABLE_COLS } from "./wo-table-cols";
type WoTableSectionHeaderProps = {
variant: "unscheduled" | "day";
label: string;
count: number;
isToday?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
onAddWo: () => void;
};
export function WoTableSectionHeader({
variant,
label,
count,
isToday,
collapsed,
onToggleCollapse,
onAddWo,
}: WoTableSectionHeaderProps) {
const isUnscheduled = variant === "unscheduled";
if (isUnscheduled) {
return (
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
<td
colSpan={WO_TABLE_COLS.length}
onClick={onToggleCollapse}
style={{
height: 40,
padding: "0 14px",
cursor: "pointer",
boxShadow: "inset 3px 0 0 var(--warning)",
borderTop: "1px solid #FDE68A",
borderBottom: "1px solid #FDE68A",
}}
>
<div className="flex items-center gap-2.5">
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--warning)",
}}
>
{label}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{count} work order{count !== 1 ? "s" : ""}
</span>
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</div>
</td>
</tr>
);
}
return (
<tr style={{ background: "#eef1f6" }}>
<td
colSpan={WO_TABLE_COLS.length}
style={{
height: 40,
padding: "0 14px",
borderTop: "1px solid var(--border)",
borderBottom: "1px solid var(--border)",
}}
>
<div className="flex items-center gap-2.5">
<span
className="uppercase"
style={{
fontFamily: "var(--font-sans)",
fontSize: 12.5,
fontWeight: 500,
letterSpacing: "0.02em",
color: "var(--muted-foreground)",
}}
>
{label}
</span>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{count} WO{count !== 1 ? "s" : ""}
</span>
{Boolean(isToday) && (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Today
</span>
)}
<button
type="button"
className="flex items-center gap-1 transition-opacity hover:opacity-70"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
onClick={(e) => {
e.stopPropagation();
onAddWo();
}}
>
<Plus size={13} />
Add WO
</button>
</div>
</td>
</tr>
);
}

View file

@ -0,0 +1,23 @@
import type { StatusVariant } from "@/components/domain/status-badge";
import { resolveStatusVariant } from "@/components/domain/status-badge-utils";
export type WoTableStatusStyle = { text: string; dot: string };
const STATUS_TABLE_STYLE: Record<StatusVariant, WoTableStatusStyle> = {
incomplete: { text: "var(--status-incomplete-text)", dot: "#9CA3AF" },
pending: { text: "var(--status-pending-text)", dot: "#D97706" },
scheduled: { text: "var(--status-scheduled-text)", dot: "#3B82F6" },
inroute: { text: "var(--status-inroute-text)", dot: "#0EA5E9" },
onsite: { text: "var(--status-onsite-text)", dot: "#10B981" },
inprogress: { text: "var(--status-inprogress-text)", dot: "#2563EB" },
completed: { text: "var(--status-completed-text)", dot: "#16A34A" },
rescheduled: { text: "var(--status-rescheduled-text)", dot: "#F59E0B" },
canceled: { text: "var(--status-canceled-text)", dot: "#9CA3AF" },
pendingquote: { text: "var(--status-pendingquote-text)", dot: "#9333EA" },
pastdue: { text: "var(--status-pastdue-text)", dot: "#BE123C" },
};
export function getWoTableStatusStyle(status: string): WoTableStatusStyle {
const variant = resolveStatusVariant(status) ?? "incomplete";
return STATUS_TABLE_STYLE[variant];
}

View file

@ -0,0 +1,14 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export const WO_TABLE_TYPE_TEXT: Record<WOType, string> = {
Emergency: "var(--destructive)",
Reactive: "#e0701a",
PM: "var(--primary)",
"Add-On": "#7c3aed",
Overdue: "#9F1239",
};
export function getWoTableTypeColor(type: WOType | ""): string {
if (!type) return "var(--color-text-muted)";
return WO_TABLE_TYPE_TEXT[type];
}

View file

@ -0,0 +1,3 @@
import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
export type ReturnTypeOfUseWorkOrdersTableData = ReturnType<typeof useWorkOrdersTableData>;

View file

@ -0,0 +1,335 @@
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,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { WoTableHead } from "./wo-table-head";
import { WoTableInlineRow } from "./wo-table-inline-row";
import { WoTableRow } from "./wo-table-row";
import { WoTableSectionHeader } from "./wo-table-section-header";
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
const CARD_STYLE: React.CSSProperties = {
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
};
type WoTableProps = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onClearSearch?: () => void;
onOpenAdvancedFilters?: () => void;
};
function WoTableShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
{children}
</div>
</div>
);
}
function WoTableEmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel: string;
onAction?: () => void;
}) {
return (
<div
className="flex h-full flex-col items-center justify-center text-center"
style={{ gap: 14, padding: 48 }}
>
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
{message}
</Text>
{Boolean(onAction) && (
<button
type="button"
onClick={onAction}
className="transition-opacity hover:opacity-80"
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
>
{actionLabel}
</button>
)}
</div>
);
}
export function WoTable({
tableData,
users,
sites,
vendors,
pmTypes,
onOpenSlideOver,
onOpenCompDoc,
onOpenMedia,
onClearSearch,
onOpenAdvancedFilters,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
});
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData;
const handlers = useMemo(
() => ({
onRowClick: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenSlideOver(row, false);
},
onView: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenSlideOver(row, false);
},
onEdit: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenSlideOver(row, true);
},
onOpenCompDoc: onOpenCompDoc
? (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenCompDoc(row);
}
: undefined,
onOpenMedia: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenMedia(row);
},
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
patchField(id, patch);
},
onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id);
onOpenSlideOver(existing, false);
},
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id),
onReorder: reorderRows,
onDragStart: (id: string | number) => {
dragStateRef.current = { dragId: id, overId: null };
setDragViz({ dragId: id, overId: null });
},
onDragOver: (id: string | number) => {
const did = dragStateRef.current.dragId;
if (did && did !== id && dragStateRef.current.overId !== id) {
dragStateRef.current.overId = id;
setDragViz((v) => ({ ...v, overId: id }));
}
},
onDrop: () => {
const { dragId, overId } = dragStateRef.current;
if (dragId && overId) {
reorderRows(dragId, overId);
}
endDrag();
},
onDragEnd: endDrag,
setFlag,
}),
[
tableData,
dragStateRef,
setDragViz,
reorderRows,
endDrag,
setFlag,
onOpenSlideOver,
onOpenCompDoc,
onOpenMedia,
patchField,
],
);
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<WoTableRow
key={String(row.id)}
row={row}
allRows={tableData.rows}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
flagColor={tableData.flags[String(row.id)]}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}
handlers={handlers}
/>
);
const handleSaveDraft = () => {
const d = tableData.draft;
const assignee = users.find((u) => String(u.id) === d.dispatcherId);
const formValues = mapWizardDraftToFormValues({
locationId: String(d.locationId || ""),
locationName: d.location || d.site,
woNumber: d.woNumber,
type: d.type,
assigneeId: d.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: d.scheduledOn,
dueDate: d.dueDate,
pm: d.pm,
vendorId: "",
vendorName: d.company,
techPhone: d.techPhone,
vendorNotes: "",
apptStart: d.apptStart,
apptEnd: d.apptEnd,
status: d.status || "Incomplete",
pocName: d.pocName,
pocPhone: d.pocPhone,
pocNotes: d.pocNotes,
scheduleWeekOnly: d.scheduleWeekOnly,
});
createMutation.mutate(formValues);
};
if (tableData.isLoading) {
return (
<WoTableShell>
<div className="flex h-full items-center justify-center p-12">
<CircularProgress />
</div>
</WoTableShell>
);
}
const emptyAdv = tableData.advActive && tableData.advResults.length === 0;
if (emptyAdv) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
</WoTableShell>
);
}
if (!tableData.advActive && tableData.noSearchResults) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
</WoTableShell>
);
}
return (
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<tbody>
{tableData.advActive ? (
tableData.advResults.map((row) => renderRow(row))
) : (
<>
{(tableData.unscheduled.length > 0 ||
tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && (
<>
<WoTableSectionHeader
variant="unscheduled"
label="Unscheduled"
count={tableData.unscheduled.length}
collapsed={!tableData.unschedOpen}
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
/>
{tableData.unschedOpen && (
<>
{tableData.draftDayKey === UNSCHEDULED_DAY_KEY && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
onChange={tableData.setDraft}
onSave={handleSaveDraft}
onCancel={tableData.cancelDraft}
/>
)}
{tableData.unscheduled.map((row) => renderRow(row, true))}
</>
)}
</>
)}
{tableData.displayGroups.map((group) => (
<Fragment key={group.key}>
<WoTableSectionHeader
variant="day"
label={group.label}
count={group.items.length}
isToday={group.key === tableData.todayKey}
onAddWo={() => tableData.startDayDraft(group.key)}
/>
{tableData.draftDayKey === group.key && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
onChange={tableData.setDraft}
onSave={handleSaveDraft}
onCancel={tableData.cancelDraft}
/>
)}
{group.items.map((row) => renderRow(row))}
</Fragment>
))}
</>
)}
</tbody>
</table>
</WoTableShell>
);
}

View file

@ -0,0 +1,53 @@
import { AppliedFilterChip } from "@/components/ui/applied-filter-chip";
import { AppliedFiltersBar } from "@/components/ui/applied-filters-bar";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
buildAdvancedFilterChips,
countAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
type WorkOrdersAdvancedFiltersBannerProps = {
applied: WorkOrderAdvancedFilters;
totalCount: number;
sites: { id: string | number; name: string }[];
users: WorkOrderUser[];
onClearChip: (chipKey: string) => void;
onClearAll: () => void;
};
export function WorkOrdersAdvancedFiltersBanner({
applied,
totalCount,
sites,
users,
onClearChip,
onClearAll,
}: WorkOrdersAdvancedFiltersBannerProps) {
if (countAdvancedFilters(applied) === 0) {
return null;
}
const siteNameOf = (id: string) => sites.find((site) => String(site.id) === id)?.name ?? id;
const dispatcherNameOf = (id: string) => users.find((user) => String(user.id) === id)?.name ?? id;
const chips = buildAdvancedFilterChips(applied, { siteNameOf, dispatcherNameOf });
return (
<AppliedFiltersBar
countLabel={`${totalCount} work order${totalCount !== 1 ? "s" : ""}`}
onClearAll={onClearAll}
>
{chips.map((chip) => (
<AppliedFilterChip
key={chip.key}
group={chip.group}
value={chip.value}
count={chip.count}
accent={chip.accent}
onDismiss={() => onClearChip(chip.key)}
/>
))}
</AppliedFiltersBar>
);
}

View file

@ -0,0 +1,56 @@
import { CircularProgress } from "@mui/material";
import { Plus, SlidersHorizontal } from "lucide-react";
import { SearchInput } from "@/components/ui/search-input";
import { ToolbarButton } from "@/components/ui/toolbar-button";
type WorkOrdersListHeaderProps = {
search: string;
onSearchChange: (value: string) => void;
/** Omit until Advanced Search slice mounts the sheet. */
onOpenAdvancedFilters?: () => void;
/** Omit until wizard shell slice mounts New WO. */
onNewWorkOrder?: () => void;
activeFilterCount: number;
isFetching?: boolean;
};
export function WorkOrdersListHeader({
search,
onSearchChange,
onOpenAdvancedFilters,
onNewWorkOrder,
activeFilterCount,
isFetching = false,
}: WorkOrdersListHeaderProps) {
return (
<>
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="Search this week…"
aria-label="Search work orders"
/>
{Boolean(onOpenAdvancedFilters) && (
<ToolbarButton
variant="outline"
icon={<SlidersHorizontal size={13} />}
onClick={onOpenAdvancedFilters}
aria-label="Advanced Filters"
title="Advanced Filters"
badge={activeFilterCount}
>
Filters
</ToolbarButton>
)}
{Boolean(onNewWorkOrder) && (
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
New WO
</ToolbarButton>
)}
{isFetching && <CircularProgress size={14} />}
</>
);
}

View file

@ -0,0 +1,117 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { Button, IconButton, Tooltip } from "@mui/material";
import { ResourceListTable, type ResourceListColumn } from "@/components/ui/resource-list-table";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
import type { SortDirection } from "@/domain/work-orders/types/work-order-list";
import { formatDateTime } from "@/lib/time-utils";
function displayDateTime(value: string): string {
return formatDateTime(value) || "—";
}
type WorkOrdersTableProps = {
rows: WorkOrderListItem[];
isLoading: boolean;
sort: { column: string; direction: SortDirection };
onSort: (column: string) => void;
onView: (id: string | number) => void;
onEdit: (id: string | number) => void;
};
export function WorkOrdersTable({
rows,
isLoading,
sort,
onSort,
onView,
onEdit,
}: WorkOrdersTableProps) {
const columns: ResourceListColumn<WorkOrderListItem>[] = [
{
id: "number",
header: "WO #",
sortable: true,
render: (row) => (
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onView(row.id)}
>
{row.number || row.id}
</Button>
),
},
{
id: "title",
header: "Title",
sortable: true,
render: (row) => row.title,
},
{
id: "location",
header: "Location",
render: (row) => row.location,
},
{
id: "priority",
header: "Priority",
render: (row) => row.priority,
},
{
id: "status",
header: "Status",
render: (row) => <WorkOrderStatusBadge status={row.status || "Open"} />,
},
{
id: "createdDate",
header: "Created",
cellClassName: "whitespace-nowrap",
render: (row) => displayDateTime(row.createdDate),
},
{
id: "assignedTo",
header: "Assigned To",
render: (row) => row.assignedTo || "Unassigned",
},
{
id: "lastUpdated",
header: "Last Updated",
cellClassName: "whitespace-nowrap",
render: (row) => displayDateTime(row.lastUpdated),
},
{
id: "actions",
header: "Actions",
align: "right",
render: (row) => (
<>
<Tooltip title="View">
<IconButton size="small" aria-label="View work order" onClick={() => onView(row.id)}>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit work order" onClick={() => onEdit(row.id)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
),
},
];
return (
<ResourceListTable
columns={columns}
rows={rows}
rowKey={(row) => String(row.id)}
isLoading={isLoading}
emptyMessage="No work orders found"
sort={sort}
onSort={onSort}
/>
);
}

View file

@ -0,0 +1,68 @@
import { type MouseEvent } from "react";
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";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploadModal({
row,
open,
onOpenChange,
uploads,
onChange,
}: MediaUploadModalProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
style: { borderRadius: 12, maxWidth: 520, width: "100%" },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
Photos &amp; Videos
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
</div>
<div className="mt-2 flex justify-end">
<Button
variant="outline"
size="default"
className="text-[12px]"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,143 @@
import { useRef } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
Upload,
X,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type MediaUploaderProps = {
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
};
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
return (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Drag files here or click to browse
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => addFiles(e.target.files)}
/>
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,119 @@
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;
};
export function VendorAndTimeSection({
containerRef,
availableVendors,
isLoadingVendors,
selectedVendorId,
onSelectVendor,
estimatedArrivalAt,
onEtaChange,
etaManualOverride,
onOverrideChange,
}: VendorAndTimeSectionProps) {
return (
<Box ref={containerRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
<SectionTitle>Vendor & Time</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Autocomplete
options={availableVendors}
loading={isLoadingVendors}
getOptionLabel={(option) =>
`${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}`
}
value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null}
onChange={(_event, option) => onSelectVendor(option?.id ?? null)}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Stack className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text as="span">
{option.companyName}
{option.contactName ? ` · ${option.contactName}` : ""}
</Text>
{option.isPreferred && <Chip label="Preferred" color="primary" size="small" />}
<Chip
label={option.availabilityStatus}
color={
option.availabilityStatus === "Available"
? "success"
: option.availabilityStatus === "Unavailable"
? "error"
: "default"
}
size="small"
/>
</Stack>
{option.conflicts.length > 0 && (
<Text variant="caption" tone="error">
Conflicts with{" "}
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
</Text>
)}
</Stack>
</li>
)}
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
className="flex-1"
/>
<TextField
label="Assignment ETA"
type="datetime-local"
value={estimatedArrivalAt}
onChange={(event) => onEtaChange(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
<FormControlLabel
control={
<Switch
checked={etaManualOverride}
onChange={(event) => onOverrideChange(event.target.checked)}
/>
}
label="Manual ETA override"
/>
</Stack>
{selectedVendorId != null &&
availableVendors.find((vendor) => vendor.id === selectedVendorId)?.availabilityStatus ===
"Unknown" && (
<Alert severity="warning" className="mt-2">
<Text as="span">
Availability is unknown. This selection is not treated as confirmed availability.
</Text>
</Alert>
)}
</Box>
);
}

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
type MiniCalendarProps = {
value: string;
onChange: (iso: string) => void;
};
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im);
const firstWeekday = new Date(viewY, viewM - 1, 1).getDay();
const daysInMonth = new Date(viewY, viewM, 0).getDate();
const cells: (number | null)[] = [
...Array(firstWeekday).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const step = (delta: number) => {
let m = viewM + delta;
let y = viewY;
if (m < 1) {
m = 12;
y -= 1;
}
if (m > 12) {
m = 1;
y += 1;
}
setViewM(m);
setViewY(y);
};
const today = todayIso();
return (
<div style={{ width: 224 }}>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => step(-1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Previous month"
>
<ChevronLeft size={14} />
</button>
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
{MONTH_NAMES[viewM - 1]} {viewY}
</span>
<button
type="button"
onClick={() => step(1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Next month"
>
<ChevronRight size={14} />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-0.5">
{WEEKDAY_HEADER.map((d, i) => (
<span
key={i}
className="py-0.5 text-center text-[10px] font-medium"
style={{ color: "var(--color-text-muted)" }}
>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) => {
if (day === null) {
return <span key={i} />;
}
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
const selected = iso === value;
const isToday = iso === today;
return (
<button
key={i}
type="button"
onClick={() => onChange(iso)}
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
style={{
height: 26,
borderRadius: 4,
fontWeight: selected || isToday ? 600 : 400,
background: selected ? "var(--color-primary)" : "transparent",
color: selected
? "var(--color-primary-foreground)"
: isToday
? "var(--color-primary)"
: "var(--color-text-primary)",
border:
isToday && !selected
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
: undefined,
}}
>
{day}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
MONTH_NAMES,
mondayOf,
todayIso,
weekRangeLabel,
weeksOfMonth,
} from "@/domain/work-orders/utils/wizard-date-utils";
type WeekPickerProps = {
selected: string;
onPick: (mondayIso: string) => void;
};
export function WeekPicker({ selected, onPick }: WeekPickerProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im);
const step = (delta: number) => {
let m = viewM + delta;
let y = viewY;
if (m < 1) {
m = 12;
y -= 1;
}
if (m > 12) {
m = 1;
y += 1;
}
setViewM(m);
setViewY(y);
};
const weeks = weeksOfMonth(viewY, viewM);
const thisMonday = mondayOf(todayIso());
return (
<div style={{ width: 240 }}>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => step(-1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Previous month"
>
<ChevronLeft size={14} />
</button>
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
{MONTH_NAMES[viewM - 1]} {viewY}
</span>
<button
type="button"
onClick={() => step(1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Next month"
>
<ChevronRight size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: "auto" }} className="flex flex-col gap-1.5">
{weeks.map((mon) => {
const isSel = Boolean(selected) && selected === mon;
const isThis = mon === thisMonday;
return (
<button
key={mon}
type="button"
onClick={() => onPick(mon)}
className="flex items-center transition-colors"
style={{
height: 36,
borderRadius: 6,
fontSize: 13,
fontWeight: isSel ? 600 : 400,
padding: "0 12px",
background: isSel ? "var(--color-primary-tint)" : "transparent",
border: `1px solid ${isSel ? "var(--color-primary)" : "var(--color-border)"}`,
color: isSel ? "var(--color-primary)" : "var(--foreground)",
}}
>
{weekRangeLabel(mon)}
{isThis && (
<span
style={{
marginLeft: "auto",
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: isSel ? "var(--color-primary)" : "var(--muted-foreground)",
}}
>
This week
</span>
)}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,169 @@
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 {
key: string;
label: string;
left?: ReactNode;
}
type WizardFieldSelectProps = {
value: string;
placeholder: string;
icon?: ReactNode;
options: WizardMenuOption[];
onPick: (key: string) => void;
search?: boolean;
searchPlaceholder?: string;
disabled?: boolean;
};
export function WizardFieldSelect({
value,
placeholder,
icon,
options,
onPick,
search = true,
searchPlaceholder = "Search…",
disabled = false,
}: WizardFieldSelectProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [q, setQ] = useState("");
const selected = options.find((o) => o.key === value);
const filtered = q
? options.filter(
(o) =>
o.label.toLowerCase().includes(q.toLowerCase()) ||
o.key.toLowerCase().includes(q.toLowerCase()),
)
: options;
const handleClose = () => {
close();
setQ("");
};
return (
<>
<MenuAnchorButton
disabled={disabled}
onAnchor={setAnchor}
open={open}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
fontFamily: "var(--font-sans)",
fontSize: 13,
color: "var(--foreground)",
outline: "none",
}}
onFocus={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
}}
aria-haspopup="listbox"
>
{icon != null && <span className="flex shrink-0 items-center">{icon}</span>}
{icon == null && selected?.left != null && (
<span className="flex shrink-0 items-center">{selected.left}</span>
)}
{selected ? (
<span
className="min-w-0 flex-1 truncate text-left"
style={{ fontSize: 13, color: "var(--foreground)" }}
>
{selected.label}
</span>
) : (
<span
className="min-w-0 flex-1 truncate text-left"
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{placeholder}
</span>
)}
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 rounded-lg border border-border p-1.5 shadow-md",
style: { width: anchorEl?.offsetWidth ?? 280 },
onClick: (e: React.MouseEvent) => e.stopPropagation(),
},
}}
>
{search && (
<div
className="mb-1 px-1 pb-1.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
style={{
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
/>
</div>
)}
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{filtered.length === 0 ? (
<Text
as="p"
className="px-2 py-2 text-center"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
No matches
</Text>
) : (
filtered.map((o) => (
<button
key={o.key}
type="button"
onClick={() => {
onPick(o.key);
handleClose();
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
<span
className="flex-1 truncate text-left text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
</span>
{value === o.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}
</button>
))
)}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,9 @@
export const DLG_INPUT_CLS =
"w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary";
export const DLG_INPUT_STYLE: React.CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};

View file

@ -0,0 +1,180 @@
import type { CSSProperties, ElementType, ReactNode } from "react";
import { Check } from "lucide-react";
export function StepCircle({ n, done, active }: { n: number; done: boolean; active: boolean }) {
const base: React.CSSProperties = {
width: 28,
height: 28,
borderRadius: 9999,
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
};
if (done) {
return (
<span
className="flex shrink-0 items-center justify-center"
style={{ ...base, background: "var(--color-success)", color: "#fff" }}
>
<Check size={14} />
</span>
);
}
if (active) {
return (
<span
className="flex shrink-0 items-center justify-center"
style={{ ...base, background: "var(--color-primary)", color: "#fff" }}
>
{n}
</span>
);
}
return (
<span
className="flex shrink-0 items-center justify-center"
style={{
...base,
background: "var(--color-bg-muted)",
color: "var(--muted-foreground)",
border: "1px solid var(--color-border)",
}}
>
{n}
</span>
);
}
const WIZ_LABEL_STYLE: CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
};
function WizOptionalTag() {
return (
<span
className="shrink-0 leading-none"
style={{
fontSize: 9,
fontWeight: 400,
letterSpacing: 0,
textTransform: "none",
color: "var(--color-text-muted)",
}}
>
optional
</span>
);
}
export function WizLabel({
children,
optional,
required,
leading,
}: {
children: ReactNode;
optional?: boolean;
required?: boolean;
leading?: ReactNode;
}) {
return (
<label
className="mb-1.5 inline-flex w-full flex-nowrap items-center gap-1.5"
style={WIZ_LABEL_STYLE}
>
{leading != null && <span className="flex shrink-0 items-center">{leading}</span>}
<span className="shrink-0">{children}</span>
{Boolean(required) && <span style={{ color: "var(--destructive)" }}>*</span>}
{Boolean(optional) && <WizOptionalTag />}
</label>
);
}
export function WizSubLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "var(--foreground)",
}}
>
{children}
</div>
);
}
export function WizLabelRow({
children,
optional,
onClear,
}: {
children: ReactNode;
optional?: boolean;
onClear?: () => void;
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2" style={{ minHeight: 16 }}>
<span
className="inline-flex min-w-0 flex-nowrap items-baseline gap-1.5"
style={WIZ_LABEL_STYLE}
>
<span className="shrink-0">{children}</span>
{Boolean(optional) && <WizOptionalTag />}
</span>
{Boolean(onClear) && (
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-0.5 transition-colors"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "var(--color-text-muted)";
}}
>
× Clear
</button>
)}
</div>
);
}
export function Detail({ icon: Icon, text }: { icon: ElementType; text: string }) {
return (
<div
className="flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
<Icon size={12} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span className="truncate">{text}</span>
</div>
);
}
export function ColorDot({ color }: { color: string }) {
return (
<span
aria-hidden
className="inline-flex shrink-0 rounded-full"
style={{
width: 8,
height: 8,
minWidth: 8,
minHeight: 8,
backgroundColor: color,
}}
/>
);
}

View file

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

View file

@ -1,25 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Controller, useForm, useWatch } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
import {
Alert,
Autocomplete,
Box,
Breadcrumbs,
Button,
CircularProgress,
Chip,
FormControlLabel,
Link,
MenuItem,
Paper,
Stack,
TextField,
Typography,
Switch,
} from "@mui/material";
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";
@ -27,28 +12,24 @@ 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 {
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>
);
}
export default function WorkOrderFormPage() {
function useWorkOrderFormState() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -103,7 +84,7 @@ export default function WorkOrderFormPage() {
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled");
setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null);
setEstimatedArrivalAt(dispatch?.estimatedArrivalAt?.slice(0, 16) ?? "");
setEstimatedArrivalAt(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt));
setEtaManualOverride(Boolean(dispatch?.etaManualOverride));
}
}, [detail, isEdit, reset]);
@ -114,9 +95,6 @@ export default function WorkOrderFormPage() {
}
}, [tradeValue, setValue]);
const locationOptions = useMemo(() => locations, [locations]);
const isSaving = saveMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
useEffect(() => {
@ -156,6 +134,70 @@ export default function WorkOrderFormPage() {
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,
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,
onSubmit,
handleCancel,
} = useWorkOrderFormState();
if (isLoading) {
return (
<Box className="flex justify-center p-12">
@ -167,9 +209,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>
);
}
@ -180,301 +220,53 @@ 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 ref={vendorSectionRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
<SectionTitle>Vendor & Time</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={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) => setSelectedVendorId(option?.id ?? null)}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Stack className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography>
{option.companyName}
{option.contactName ? ` · ${option.contactName}` : ""}
</Typography>
{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 && (
<Typography variant="caption" color="error">
Conflicts with{" "}
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
</Typography>
)}
</Stack>
</li>
)}
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
className="flex-1"
/>
<TextField
label="Assignment ETA"
type="datetime-local"
value={estimatedArrivalAt}
onChange={(event) => setEstimatedArrivalAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
<FormControlLabel
control={
<Switch
checked={etaManualOverride}
onChange={(event) => setEtaManualOverride(event.target.checked)}
/>
}
label="Manual ETA override"
/>
</Stack>
{selectedVendorId != null &&
availableVendors.find((vendor) => vendor.id === selectedVendorId)
?.availabilityStatus === "Unknown" && (
<Alert severity="warning" className="mt-2">
Availability is unknown. This selection is not treated as confirmed availability.
</Alert>
)}
</Box>
<VendorAndTimeSection
containerRef={vendorSectionRef}
availableVendors={availableVendors}
isLoadingVendors={isLoadingVendors}
selectedVendorId={selectedVendorId}
onSelectVendor={setSelectedVendorId}
estimatedArrivalAt={estimatedArrivalAt}
onEtaChange={setEstimatedArrivalAt}
etaManualOverride={etaManualOverride}
onOverrideChange={setEtaManualOverride}
/>
<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>
<ClassificationFields
control={control}
problems={problems}
trades={trades}
subTrades={subTrades}
tradeValue={tradeValue}
/>
<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>
<ScheduleFields
register={register}
control={control}
errors={errors}
employees={employees}
/>
<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>
<FinancialFields register={register} />
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
<DescriptionFields register={register} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>

View file

@ -0,0 +1,58 @@
import { useCallback, useState } from "react";
import type {
SortDirection,
WorkOrdersSortableColumn,
} from "@/domain/work-orders/types/work-order-list";
type TableSortState = {
column: WorkOrdersSortableColumn | "";
direction: SortDirection;
};
export function useTableSort(initialColumn: WorkOrdersSortableColumn | "" = "") {
const [sortBy, setSortBy] = useState<WorkOrdersSortableColumn | "">(initialColumn);
const [sortDir, setSortDir] = useState<SortDirection>("");
const handleSort = useCallback((column: WorkOrdersSortableColumn | string) => {
const col = column as WorkOrdersSortableColumn;
setSortBy((currentColumn) => {
if (currentColumn !== col) {
setSortDir("asc");
return col;
}
setSortDir((currentDir) => {
if (currentDir === "asc") {
return "desc";
}
if (currentDir === "desc") {
setSortBy("");
return "";
}
return "asc";
});
return currentColumn;
});
}, []);
const sortIndicator = useCallback(
(column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : sortDir === "asc" ? " ↑" : "";
},
[sortBy, sortDir],
);
const sort: TableSortState = { column: sortBy, direction: sortDir };
return {
sortBy,
sortDir,
sort,
handleSort,
sortIndicator,
};
}

View file

@ -0,0 +1,191 @@
import { useMemo, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type {
WorkOrderBarFilters,
WorkOrderTypeFilter,
} from "@/domain/work-orders/types/work-order-list-filters";
import {
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
getInitialAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import {
compactWeekLabel,
currentWeekMonday,
weekRange,
} from "@/domain/work-orders/utils/list-week-utils";
function defaultDispatcherIds(currentUserId?: string | number): Set<string> {
if (isWoTableMockEnabled()) {
return new Set();
}
if (currentUserId != null && currentUserId !== "") {
return new Set([String(currentUserId)]);
}
return new Set();
}
/** Phase 2: map bar filters to API list params. */
export function toListParamsFromBar(
bar: WorkOrderBarFilters,
): Pick<WorkOrdersListParams, "dateFrom" | "dateTo" | "type" | "assigneeIds"> {
const [dateFrom, dateTo] = weekRange(bar.weekMonday);
return {
dateFrom,
dateTo,
type: bar.type === "All" ? undefined : bar.type,
assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined,
};
}
export function useWorkOrdersListFilters(currentUserId?: string | number) {
const [search, setSearch] = useState("");
const [advOpen, setAdvOpen] = useState(false);
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() =>
defaultDispatcherIds(currentUserId),
);
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
const debouncedSearch = useDebounce(search, 300);
const barFilters = useMemo<WorkOrderBarFilters>(
() => ({
weekMonday,
dispatcherIds,
type: typeFilter,
}),
[weekMonday, dispatcherIds, typeFilter],
);
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
const scopeListParams = useMemo<WorkOrdersListParams>(
() => ({
page: 1,
pageSize: 1,
...toListParamsFromBar(barFilters),
}),
[barFilters],
);
const listParams = useMemo<WorkOrdersListParams>(
() => ({
search: debouncedSearch,
page: 1,
pageSize: WORK_ORDERS_TABLE_PAGE_SIZE,
sortBy,
sortDir,
...toListParamsFromBar(barFilters),
}),
[debouncedSearch, sortBy, sortDir, barFilters],
);
const activeFilterCount = useMemo(
() => (advApplied ? countAdvancedFilters(advApplied) : 0),
[advApplied],
);
const setWeekMonday = (value: string) => {
setWeekMondayState(value);
setPage(0);
};
const setDispatcherIds = (value: Set<string>) => {
setDispatcherIdsState(new Set(value));
setPage(0);
};
const setTypeFilter = (value: WorkOrderTypeFilter) => {
setTypeFilterState(value);
setPage(0);
};
const handleSearchChange = (value: string) => {
setSearch(value);
setPage(0);
};
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
setAdvApplied(cloneAdvancedFilters(filters));
setPage(0);
};
const clearAdvancedFilters = () => {
setAdvApplied(null);
setPage(0);
};
const clearAdvancedFilterChip = (chipKey: string) => {
if (!advApplied) return;
const next = clearAdvancedFilterGroup(advApplied, chipKey);
setAdvApplied(next);
setPage(0);
};
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => {
const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc";
if (next === "") {
setSortBy("");
}
return next;
});
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
return {
search,
advOpen,
advApplied,
advInitial: getInitialAdvancedFilters(advApplied),
page,
pageSize,
sortBy,
sortDir,
weekMonday,
dispatcherIds,
typeFilter,
barFilters,
weekLabel,
listParams,
scopeListParams,
activeFilterCount,
handleSearchChange,
applyAdvancedFilters,
clearAdvancedFilters,
clearAdvancedFilterChip,
handleSort,
sortIndicator,
setAdvOpen,
setPage,
setPageSize,
setWeekMonday,
setDispatcherIds,
setTypeFilter,
setSortBy,
setSortDir,
};
}

View file

@ -0,0 +1,222 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
EMPTY_TABLE_DRAFT,
UNSCHEDULED_DAY_KEY,
type WorkOrderTableDraft,
type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
buildDayGroups,
enrichTableRow,
filterByBarAndSearch,
matchesAdvancedFilters,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
export type DragVizState = {
dragId: string | number | null;
overId: string | number | null;
};
export function useWorkOrdersTableData(filters: FiltersReturn) {
const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams);
const { data: scopeData } = useWorkOrdersList(filters.scopeListParams);
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
const [rowOrder, setRowOrder] = useState<string[] | null>(null);
const [flags, setFlagsState] = useState<Record<string, string>>({});
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
const [unschedOpen, setUnschedOpen] = useState(true);
const [selectedId, setSelectedId] = useState<string | number | null>(null);
const [flashId, setFlashId] = useState<string | number | null>(null);
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
const apiRows = useMemo(
() => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
[data?.items, patches],
);
const displayRows = useMemo(() => {
const apiIds = new Set(apiRows.map((r) => String(r.id)));
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
let merged = [...apiRows, ...extra.map(enrichTableRow)];
if (rowOrder?.length) {
const orderMap = new Map(rowOrder.map((id, idx) => [id, idx]));
merged = [...merged].sort((a, b) => {
const ai = orderMap.get(String(a.id)) ?? 9999;
const bi = orderMap.get(String(b.id)) ?? 9999;
return ai - bi;
});
}
return merged;
}, [apiRows, localRows, rowOrder]);
const advActive = filters.advApplied !== null;
const advResults = useMemo(() => {
if (!filters.advApplied) {
return [];
}
return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!));
}, [displayRows, filters.advApplied]);
const filtered = useMemo(() => {
if (advActive) {
return [];
}
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
}, [displayRows, filters.barFilters, filters.search, advActive]);
const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
const searchActive = filters.search.trim() !== "";
const noSearchResults = filtered.length === 0 && searchActive;
const isEmptyWeek = scheduled.length === 0 && !searchActive;
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
const todayKey =
filters.weekMonday === currentWeekMonday()
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
: null;
const selectedRow = useMemo(
() => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null,
[displayRows, selectedId],
);
const setFlag = useCallback((id: string | number, color: string | null) => {
setFlagsState((prev) => {
const key = String(id);
if (!color) {
const next = { ...prev };
delete next[key];
return next;
}
return { ...prev, [key]: color };
});
}, []);
const patchRow = useCallback((id: string | number, patch: Partial<WorkOrderTableRow>) => {
const key = String(id);
setPatches((prev) => {
const merged = { ...prev[key], ...patch };
if (patch.scheduledOn !== undefined) {
const dayFields = deriveDayFields(patch.scheduledOn);
merged.dayGroup = dayFields.dayGroup;
merged.dayLabel = dayFields.dayLabel;
}
return { ...prev, [key]: merged };
});
setLocalRows((prev) =>
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
);
}, []);
const reorderRows = useCallback(
(dragId: string | number, overId: string | number) => {
const dragKey = String(dragId);
const overKey = String(overId);
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
const overRow = displayRows.find((r) => String(r.id) === overKey);
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) {
return;
}
const ids = displayRows.map((r) => String(r.id));
const dragIdx = ids.indexOf(dragKey);
const overIdx = ids.indexOf(overKey);
if (dragIdx < 0 || overIdx < 0) {
return;
}
const next = [...ids];
next.splice(dragIdx, 1);
next.splice(overIdx, 0, dragKey);
setRowOrder(next);
},
[displayRows],
);
const endDrag = useCallback(() => {
dragState.current = { dragId: null, overId: null };
setDragViz({ dragId: null, overId: null });
}, []);
const startDayDraft = useCallback(
(key: string) => {
setDraftDayKey(key);
const scheduledOn =
key === UNSCHEDULED_DAY_KEY
? ""
: (() => {
const [mm, dd] = key.split("/").map(Number);
const year = filters.weekMonday.slice(0, 4);
return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
})();
setDraft({
...EMPTY_TABLE_DRAFT,
scheduledOn,
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "",
});
},
[filters.weekMonday],
);
const cancelDraft = useCallback(() => {
setDraftDayKey(null);
setDraft(EMPTY_TABLE_DRAFT);
}, []);
const addRow = useCallback((row: WorkOrderTableRow) => {
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
setFlashId(row.id);
setTimeout(() => setFlashId(null), 2000);
}, []);
return {
rows: displayRows,
isLoading,
isFetching,
advActive,
advResults,
unscheduled,
displayGroups,
searchActive,
noSearchResults,
isEmptyWeek,
todayKey,
flags,
setFlag,
draftDayKey,
draft,
setDraft,
unschedOpen,
setUnschedOpen,
selectedId,
setSelectedId,
selectedRow,
flashId,
dragViz,
setDragViz,
dragState,
patchRow,
reorderRows,
endDrag,
startDayDraft,
cancelDraft,
addRow,
scopeTotalCount: scopeData?.totalCount ?? 0,
totalCount: data?.totalCount ?? displayRows.length,
search: filters.search,
};
}

View file

@ -1,337 +1,125 @@
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { PageHeader } from "@/components/ui/page-header";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderUsers,
useWorkOrdersList,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
function formatDateTime(value: string): string {
if (!value) {
return "—";
}
const normalized = value.endsWith("Z") ? value : `${value}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return value;
}
const datePart = date.toLocaleDateString("en-CA");
const timePart = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
}
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const { user } = useAuthContext();
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
const debouncedSearch = useDebounce(search, 300);
const { data: users = [] } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
sortBy,
sortDir,
status: statusFilter,
assignee: assigneeFilter,
}),
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
const pmTypes = useMemo(
() =>
isWoTableMockEnabled()
? getMockPmTypes()
: problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
const { data: users = [] } = useWorkOrderUsers();
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
if (sortDir === "desc") {
setSortBy("");
}
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
const handleStatusFilter = (status: string) => {
setStatusFilter((current) => (current === status ? "" : status));
setPage(0);
};
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Work Orders
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search..."
className="min-w-[220px]"
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
eyebrow="OPERATIONS"
title="Work Orders"
actions={
<WorkOrdersListHeader
search={filters.search}
onSearchChange={filters.handleSearchChange}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
+ New Work Order
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "center",
}
/>
<WorkOrdersFilterBar
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
/>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
sites={locations}
users={users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
onOpenSlideOver={(row, edit) => {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
>
<Chip
label="All"
clickable
color={statusFilter === "" ? "primary" : "default"}
variant={statusFilter === "" ? "filled" : "outlined"}
onClick={() => {
setStatusFilter("");
setPage(0);
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
/>
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
}}
/>
{STATUS_OPTIONS.map((status) => (
<Chip
key={status}
label={status}
clickable
color={statusFilter === status ? "primary" : "default"}
variant={statusFilter === status ? "filled" : "outlined"}
onClick={() => handleStatusFilter(status)}
/>
))}
<FormControl size="small" className="min-w-[180px]">
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
<Select
labelId="assignee-filter-label"
label="Assignee"
value={assigneeFilter}
onChange={(event) => {
setAssigneeFilter(event.target.value);
setPage(0);
}}
>
<MenuItem value="">All Assignees</MenuItem>
<MenuItem value="__unassigned">Unassigned</MenuItem>
{users.map((user) => (
<MenuItem key={String(user.id)} value={user.name}>
{user.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell
className="cursor-pointer whitespace-nowrap font-semibold"
onClick={() => handleSort("number")}
>
WO #{sortIndicator("number")}
</TableCell>
<TableCell
className="cursor-pointer font-semibold"
onClick={() => handleSort("title")}
>
Title{sortIndicator("title")}
</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Created</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Last Updated</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderTableRow) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.woNumber || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
<TableCell>{row.location}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.scheduledOn)}
</TableCell>
<TableCell>{row.dispatcherName || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
size="small"
aria-label="View work order"
onClick={() => navigate(`/workorders/${row.id}`)}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit work order"
onClick={() => navigate(`/workorders/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
</Box>
);
}

View file

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

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