mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(vendors): merge dev and address review
This commit is contained in:
commit
900af790c0
129 changed files with 8379 additions and 1512 deletions
27
.github/workflows/ci.yaml
vendored
27
.github/workflows/ci.yaml
vendored
|
|
@ -11,6 +11,33 @@ permissions:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
governance:
|
||||
# Repo-owned guarantee that every frontend quality gate runs from this
|
||||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
|
||||
# maintainability gate). If the reusable workflow is later confirmed to run
|
||||
# every gate, this job can be slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
# push-> the previous commit on the branch (github.event.before)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
|
|
|||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# AGENTS.md — frontend mandatory conventions
|
||||
|
||||
This file is the binding entry point for **every contributor — human or AI coding
|
||||
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
|
||||
frontend). It makes the validated React/TypeScript conventions mandatory and
|
||||
points to the operational documents and executable gates that enforce them.
|
||||
|
||||
Read these before writing or reviewing code. They override generic "best
|
||||
practice" suggestions from any agent or model. This repository contract may
|
||||
strengthen, but never weaken, the workspace-level `AGENTS.md`.
|
||||
|
||||
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
|
||||
command, and the no-false-pass guarantees.
|
||||
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
|
||||
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
|
||||
data access, security, maintainability) with evidence and thresholds.
|
||||
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
|
||||
contract (exact-head review, board regression inventory, behavior-based
|
||||
testing, security/performance/Big-O review, no style-only comments).
|
||||
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
|
||||
detailed rationale for the conditional-rendering and typography rules.
|
||||
|
||||
## One command to run every gate
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||
`governance` job, so a green local run mirrors CI.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
These are already enforced by lint/build or the governance script. Disabling,
|
||||
baselining, or per-line-suppressing them is forbidden (see exceptions below).
|
||||
|
||||
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
|
||||
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
|
||||
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
|
||||
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
|
||||
the `vp-error` class outside `Text` are rejected.
|
||||
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
|
||||
do not silence it.
|
||||
- **Hooks correctness** — the `react-hooks` recommended rules (including
|
||||
`exhaustive-deps`) run under the zero-warnings gate.
|
||||
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
|
||||
has a frozen per-file cap that may only decrease.
|
||||
- **Changed-file maintainability** — changed TS/TSX must meet
|
||||
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
|
||||
|
||||
## How to add / change a convention
|
||||
|
||||
1. Land it **green**: a new or tightened rule must ship with the codebase passing
|
||||
it (a migration in the same change), not as a warning-only backlog.
|
||||
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
|
||||
new baseline debt or raise a frozen cap.
|
||||
3. Document the rule, its threshold, and its evidence in
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||
|
||||
## Exceptions and the ADR process
|
||||
|
||||
Exceptions are **not granted by disabling a rule inline**. To deviate:
|
||||
|
||||
1. Record an **Architecture Decision Record** under `docs/adr/`
|
||||
(`NNNN-title.md`: context, decision, consequences, alternatives).
|
||||
2. Existing grandfathered entries may only be removed or have their caps
|
||||
reduced. New entries and cap increases fail the governance gate.
|
||||
3. Get it reviewed like any other change. The ADR + baseline entry is the
|
||||
auditable record; an `eslint-disable` comment is not.
|
||||
|
||||
## Toolchain (do not change without an ADR)
|
||||
|
||||
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
|
||||
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
|
||||
`packageManager`, invoked through corepack). **No new dependencies without an
|
||||
ADR** — prefer the libraries already established (see
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
|
||||
data access).
|
||||
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
|
||||
|
||||
Mandatory React/TypeScript conventions for this repository. Each rule lists how
|
||||
it is enforced. Detailed rationale for rendering and typography lives in
|
||||
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
|
||||
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
|
||||
means it is a review-enforced convention backed by the
|
||||
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
|
||||
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
|
||||
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
|
||||
semantics and TypeScript narrowing with `count > 0`, `value != null`.
|
||||
`{count && <X/>}` renders `0` and is rejected by the type-aware
|
||||
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
|
||||
the approved guard forms.)
|
||||
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
|
||||
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
|
||||
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
|
||||
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
|
||||
`vp-error` class on any non-`Text` element is rejected
|
||||
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
|
||||
bypass the static check.
|
||||
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
|
||||
omitted from `TextProps` so the variant contract (element, tone, live region)
|
||||
cannot be overridden. Use `as` and `tone`.
|
||||
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
|
||||
with `when`; mount `error` (assertive `alert`) on demand.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
- **MUST** use the libraries already established: **React Hook Form** for field
|
||||
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
|
||||
Query** for server reads and mutations (pending/error state, invalidation,
|
||||
retries).
|
||||
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
|
||||
second form convention increases cognitive and dependency cost. Replacing
|
||||
React Hook Form requires an approved repo-wide migration ADR with measured
|
||||
benefits, a codemod/migration plan, and removal of the superseded dependency.
|
||||
- **SHOULD** keep file upload selection/validation in a focused component and
|
||||
drive upload progress/errors/retry through a TanStack Query mutation, not in a
|
||||
route-sized page.
|
||||
|
||||
## State ownership (bounded)
|
||||
|
||||
- **MUST** keep query loading/error/empty state adjacent to the query result;
|
||||
mutation pending/error state in the component that initiated the mutation.
|
||||
- **MUST** compose focused state components instead of accumulating unrelated
|
||||
booleans in a page. Route pages coordinate sections and navigation; reusable
|
||||
sections own their interaction details.
|
||||
- **SHOULD** render errors inline with accessible feedback (`Text
|
||||
variant="error"`); reserve toasts for cross-page outcomes.
|
||||
|
||||
## Data access
|
||||
|
||||
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
|
||||
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
|
||||
break caching and invalidation. (Review-enforced; a future lint rule is
|
||||
tracked as a gap.)
|
||||
- **MUST NOT** materialize large server collections into client state and then
|
||||
filter/sort them in the component when the server (or a memoized, virtualized
|
||||
layer) should own it. Prefer server-side filtering/pagination; if client-side
|
||||
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
|
||||
- **MUST** invalidate the right query keys after a mutation so caches do not
|
||||
show stale data.
|
||||
|
||||
## Security
|
||||
|
||||
- **MUST NOT** surface raw server error payloads, stack traces, or internal
|
||||
identifiers to end users. Map server errors to a safe user-facing message
|
||||
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
|
||||
(Review-enforced.)
|
||||
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
|
||||
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
|
||||
never put a secret in a `VITE_` variable.
|
||||
|
||||
## Hooks, performance, and Big-O
|
||||
|
||||
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
|
||||
under the zero-warnings gate.
|
||||
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
|
||||
data, key lists stably, and prefer server-side filtering for large sets
|
||||
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
|
||||
only when measured to matter (no speculative memoization).
|
||||
|
||||
## Maintainability (measured thresholds)
|
||||
|
||||
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
|
||||
These are conservative code-shape proxies for review focus. File length is not
|
||||
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
|
||||
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
|
||||
judgment.
|
||||
|
||||
| Metric | Threshold | How enforced |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
|
||||
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
|
||||
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
|
||||
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
|
||||
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
|
||||
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
|
||||
|
||||
**Why these are changed-file ratchets, not whole-repo errors:** measured against
|
||||
the current codebase, the maintainability thresholds surface **72 violations
|
||||
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
|
||||
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
|
||||
legacy without a migration. Instead:
|
||||
|
||||
- The **5 oversized files** are explicit grandfathered debt with frozen
|
||||
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
|
||||
grow; new entries and cap increases fail.
|
||||
- The **complexity/function-length/params/depth** rules apply to **changed**
|
||||
TS/TSX on a PR/push, so new and modified code must comply while untouched
|
||||
legacy is not blocked. Bring legacy into compliance when you next touch it.
|
||||
|
||||
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
|
||||
entries) as debt is paid down. Never raise them to make a change pass.
|
||||
54
QUALITY_GATES.md
Normal file
54
QUALITY_GATES.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# QUALITY_GATES.md — executable frontend gates
|
||||
|
||||
The single command that runs **every** gate, locally and in CI:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
|
||||
backlog; rules ship green (see AGENTS.md → How to add a convention).
|
||||
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
|
||||
when type services are unavailable rather than silently claiming safety.
|
||||
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
|
||||
entry, global cap increase, per-file cap increase, or growth beyond a frozen
|
||||
legacy cap fails. Only cap reductions and entry removals are allowed.
|
||||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
28
README.md
28
README.md
|
|
@ -95,15 +95,17 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
|||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ----------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -132,7 +134,13 @@ CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
|||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests.
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
|
||||
guaranteed from this repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||
|
|
|
|||
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
|
||||
|
||||
Every PR is reviewed against this contract, by humans and by review agents. The
|
||||
goal is high-signal review: catch real behavior, security, performance, and
|
||||
regression problems — not re-lint what the gates already enforce.
|
||||
|
||||
## 1. Review the exact head
|
||||
|
||||
- **MUST** review the diff at the **current PR head**, not a stale checkout.
|
||||
Re-pull before reviewing if new commits landed; stale approvals are dismissed
|
||||
on push by branch protection.
|
||||
- **MUST** read the full diff of every changed file, including renames and
|
||||
generated/mapper code, not only the "interesting" components.
|
||||
|
||||
## 2. Board-backed regression inventory
|
||||
|
||||
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
|
||||
all visible tickets for this area, not only the current ticket. Behavior that
|
||||
is Done / QA-approved / released is **protected scope**.
|
||||
- **MUST** treat a plausible regression against protected behavior as a
|
||||
**Blocker** until disproven with repo evidence (the accepting tests, the linked
|
||||
PR/release, and a targeted check of the changed code paths).
|
||||
- **MUST NOT** approve if board access or the relevant inventory is missing —
|
||||
say so and block rather than infer a pass. A missing ticket link alone is not
|
||||
a blocker, but known acceptance criteria must still be traced.
|
||||
|
||||
The current Jira workflow has no `Ready for QA` transition. Merged work remains
|
||||
in the documented pre-QA status until QA evidence supports `Done`; do not invent
|
||||
a status or mark unverified work Done.
|
||||
|
||||
## 3. Behavior-based testing
|
||||
|
||||
- **MUST** test through **public behavior** (rendered output, user interactions,
|
||||
query/mutation outcomes), not internal implementation details. Prefer
|
||||
`@testing-library` queries and user-event flows; assert what users observe.
|
||||
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
|
||||
governance script) executes — validate tooling by running the real gates
|
||||
(`npm run verify`), not with assertion-free unit tests.
|
||||
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
|
||||
error/empty state, and any boundary the change introduces.
|
||||
|
||||
## 4. Security review
|
||||
|
||||
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
|
||||
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
|
||||
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
|
||||
`VITE_*` variable carries a secret (it is baked into the bundle).
|
||||
- **SHOULD** check untrusted input is validated (Zod) before use and that
|
||||
dangerously-set HTML / unescaped server strings are not introduced.
|
||||
|
||||
## 5. Performance and Big-O review
|
||||
|
||||
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
|
||||
collections, re-filtering/sorting on every render, unbounded list rendering
|
||||
without virtualization.
|
||||
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
|
||||
the correct keys (no stale cache, no redundant refetch storms).
|
||||
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
|
||||
unstable identities passed to memoized children.
|
||||
|
||||
## 6. No style-only comments
|
||||
|
||||
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||
gates; review is for behavior, correctness, security, and architecture.
|
||||
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
|
||||
evidence-based convention in these docs, and offer a concrete fix or a
|
||||
targeted question. Use GitHub suggestion blocks when safe.
|
||||
|
||||
## 7. Review close-out
|
||||
|
||||
A review is complete when it records, briefly:
|
||||
|
||||
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
|
||||
"no findings".
|
||||
2. Open questions and their owner.
|
||||
3. Which of the above checks were run, and any that were skipped (and why).
|
||||
4. Residual risk, if approving.
|
||||
|
||||
Do not write a monolithic review body or a validation transcript into the PR
|
||||
surface; keep comments inline and high-signal.
|
||||
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Frontend maintainability conventions
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||
state. Use a ternary only when both branches render meaningful alternatives.
|
||||
|
||||
```tsx
|
||||
{
|
||||
error && <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
<Text variant="description" when={Boolean(description)}>
|
||||
{description}
|
||||
</Text>;
|
||||
```
|
||||
|
||||
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||
distinct from real either-or UI decisions.
|
||||
|
||||
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
|
||||
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
|
||||
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
|
||||
TypeScript checker for the type of the left operand and reports unless every union constituent is
|
||||
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
|
||||
fails closed: if type services are unavailable in a governed TSX file it reports rather than
|
||||
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
|
||||
same constraint at that component boundary.
|
||||
|
||||
Approved guard forms (choose by semantics, not as a blind codemod):
|
||||
|
||||
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
|
||||
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
|
||||
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
|
||||
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
|
||||
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
|
||||
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
|
||||
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
|
||||
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
|
||||
narrowing.
|
||||
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
|
||||
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
|
||||
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
|
||||
|
||||
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
|
||||
that needs a non-boolean operand must be rewritten into one of the approved forms above.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||
code, and asynchronous feedback. It owns:
|
||||
|
||||
- semantic HTML for each visual variant;
|
||||
- the display, body, and monospace font families;
|
||||
- default, muted, error, success, and warning tones;
|
||||
- accessible `alert` and `status` live regions for error and feedback text;
|
||||
- one-sided conditional text through `when`.
|
||||
|
||||
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
|
||||
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
|
||||
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
|
||||
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
|
||||
`aria-live` directly.
|
||||
|
||||
Live-region behavior:
|
||||
|
||||
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
|
||||
the polite region exists before its content changes and is announced reliably. Mounting an
|
||||
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
|
||||
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
|
||||
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
|
||||
|
||||
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
|
||||
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
|
||||
|
||||
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
|
||||
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
|
||||
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
|
||||
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
|
||||
static surface only. It cannot resolve dynamic or composed class values
|
||||
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
|
||||
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
Use the libraries already established in the application:
|
||||
|
||||
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||
- Zod owns form validation and inferred form value types.
|
||||
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||
invalidation, and retries where safe.
|
||||
|
||||
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||
benchmarks, a codemod plan, and an approved deprecation path.
|
||||
|
||||
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||
retry state. Do not place upload orchestration in a route-sized page component.
|
||||
|
||||
## Page state
|
||||
|
||||
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||
|
||||
- query loading, error, and empty states stay adjacent to the query result;
|
||||
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||
- route pages coordinate sections and navigation;
|
||||
- reusable sections own their interaction details;
|
||||
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||
|
||||
## Enforcement and rollout
|
||||
|
||||
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||
67
e2e/vendors/vendor-operations.spec.ts
vendored
67
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
|
||||
* boolean.
|
||||
*
|
||||
* `{value && <Element />}` renders its left operand when it is falsy and
|
||||
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
|
||||
* must be `boolean` (or boolean literals) across the whole union. The rule
|
||||
* asks the TypeScript checker for the type of the left operand and reports
|
||||
* unless every union constituent is boolean-like.
|
||||
*
|
||||
* Type-aware by design: a selector that cannot see operand types would give
|
||||
* false confidence rather than enforcement. If type services are unexpectedly
|
||||
* unavailable in a governed TSX file, the rule fails closed (reports) instead
|
||||
* of silently claiming the expression is safe.
|
||||
*/
|
||||
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
|
||||
|
||||
function isBooleanLikeType(type) {
|
||||
return (type.flags & booleanLikeFlags) !== 0;
|
||||
}
|
||||
|
||||
function isEntirelyBoolean(type) {
|
||||
if (type.isUnion()) {
|
||||
return type.types.every((constituent) => isBooleanLikeType(constituent));
|
||||
}
|
||||
return isBooleanLikeType(type);
|
||||
}
|
||||
|
||||
const transparentExpressionTypes = new Set([
|
||||
"ChainExpression",
|
||||
"ParenthesizedExpression",
|
||||
"TSAsExpression",
|
||||
"TSNonNullExpression",
|
||||
"TSTypeAssertion",
|
||||
]);
|
||||
|
||||
function isInRenderedPosition(node) {
|
||||
let current = node;
|
||||
while (current.parent) {
|
||||
const parent = current.parent;
|
||||
if (parent.type === "JSXExpressionContainer") {
|
||||
return parent.parent?.type !== "JSXAttribute";
|
||||
}
|
||||
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the left operand of `&&` in JSX to be entirely boolean",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonBooleanLeft:
|
||||
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
|
||||
typeServicesUnavailable:
|
||||
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
||||
|
||||
return {
|
||||
LogicalExpression(node) {
|
||||
if (node.operator !== "&&") {
|
||||
return;
|
||||
}
|
||||
if (!isInRenderedPosition(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!services || services.program === null) {
|
||||
context.report({ node, messageId: "typeServicesUnavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
const leftType = services.getTypeAtLocation(node.left);
|
||||
if (!isEntirelyBoolean(leftType)) {
|
||||
context.report({ node: node.left, messageId: "nonBooleanLeft" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
55
eslint-rules/no-vp-error-outside-text.js
Normal file
55
eslint-rules/no-vp-error-outside-text.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
function containsVpErrorToken(node) {
|
||||
if (!node || typeof node !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type === "Literal" && typeof node.value === "string") {
|
||||
return node.value.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
if (node.type === "TemplateElement") {
|
||||
return node.value.raw.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
return Object.entries(node).some(([key, value]) => {
|
||||
if (key === "parent") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsVpErrorToken);
|
||||
}
|
||||
return containsVpErrorToken(value);
|
||||
});
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the Text component for legacy vp-error styling",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useText:
|
||||
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementName = node.parent?.name;
|
||||
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({ node, messageId: "useText" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
|
|
@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
|
|||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
|
||||
import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
|
||||
|
||||
const localRules = {
|
||||
rules: {
|
||||
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
|
||||
"no-vp-error-outside-text": noVpErrorOutsideText,
|
||||
},
|
||||
};
|
||||
|
||||
const legacyIgnores = [
|
||||
"src/pages/**",
|
||||
"src/app/store.js",
|
||||
|
|
@ -54,6 +64,7 @@ export default tseslint.config(
|
|||
plugins: {
|
||||
"react-hooks": pluginReactHooks,
|
||||
"react-refresh": pluginReactRefresh,
|
||||
seahaven: localRules,
|
||||
},
|
||||
rules: {
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
|
|
@ -62,6 +73,23 @@ export default tseslint.config(
|
|||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"seahaven/no-non-boolean-jsx-and": "error",
|
||||
"seahaven/no-vp-error-outside-text": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
|
||||
message:
|
||||
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
|
||||
message:
|
||||
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@
|
|||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"governance": "node scripts/governance-check.mjs",
|
||||
"verify": "npm run format:check && npm run lint && npm run build && npm test && npm run governance",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
|
|||
27
scripts/governance-baseline.json
Normal file
27
scripts/governance-baseline.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"version": 1,
|
||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||
"maxFileLines": 500,
|
||||
"godfileDebt": [
|
||||
{
|
||||
"path": "src/app/(protected)/workorders/[id].tsx",
|
||||
"maxLines": 797,
|
||||
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
|
||||
},
|
||||
{
|
||||
"path": "src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx",
|
||||
"maxLines": 782,
|
||||
"reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields."
|
||||
},
|
||||
{
|
||||
"path": "src/app/(protected)/vendors/index.tsx",
|
||||
"maxLines": 612,
|
||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||
},
|
||||
{
|
||||
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
|
||||
"maxLines": 572,
|
||||
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
|
||||
}
|
||||
]
|
||||
}
|
||||
289
scripts/governance-check.mjs
Normal file
289
scripts/governance-check.mjs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
||||
|
||||
const MAX_FILE_LINES = 500;
|
||||
const MAINTAINABILITY_RULES = [
|
||||
'complexity: ["error", { "max": 20 }]',
|
||||
'max-lines-per-function: ["error", { "skipComments": true, "max": 150 }]',
|
||||
'max-params: ["error", 4]',
|
||||
'max-depth: ["error", 4]',
|
||||
];
|
||||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
GOVERNED_ROOTS.some((root) => relativePath.startsWith(root)) &&
|
||||
/\.(ts|tsx)$/.test(relativePath) &&
|
||||
!EXCLUDE_DIR.test(relativePath) &&
|
||||
!EXCLUDE_NAME.test(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function gitText(args) {
|
||||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function gitLines(args) {
|
||||
return gitText(args).split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function governedFiles() {
|
||||
const tracked = gitLines(["ls-files"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...tracked, ...untracked])].filter(isGoverned);
|
||||
}
|
||||
|
||||
function lineCount(relativePath) {
|
||||
const content = readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function readBaseline() {
|
||||
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function readBaselineAtRef(ref) {
|
||||
try {
|
||||
const content = execFileSync("git", ["show", `${ref}:scripts/governance-baseline.json`], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function godfileRatchet(baseRef) {
|
||||
const baseline = readBaseline();
|
||||
const cap = baseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
const debtEntries = new Map(
|
||||
(baseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
const files = governedFiles();
|
||||
|
||||
const newDebt = [];
|
||||
const grownDebt = [];
|
||||
for (const file of files) {
|
||||
const lines = lineCount(file);
|
||||
const debtCap = debtEntries.get(file);
|
||||
if (lines > cap && debtCap === undefined) {
|
||||
newDebt.push({ path: file, lines });
|
||||
} else if (debtCap !== undefined && lines > debtCap) {
|
||||
grownDebt.push({ path: file, lines, maxLines: debtCap });
|
||||
}
|
||||
}
|
||||
|
||||
const stale = [];
|
||||
const remaining = [];
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const lines = files.includes(debtPath) ? lineCount(debtPath) : -1;
|
||||
if (lines === -1 || lines <= cap) {
|
||||
stale.push({ path: debtPath, lines });
|
||||
} else {
|
||||
remaining.push({ path: debtPath, lines, maxLines });
|
||||
}
|
||||
}
|
||||
|
||||
const baselineLoosening = [];
|
||||
const baseBaseline = baseRef ? readBaselineAtRef(baseRef) : null;
|
||||
if (baseBaseline) {
|
||||
const baseCap = baseBaseline.maxFileLines ?? MAX_FILE_LINES;
|
||||
if (cap > baseCap) {
|
||||
baselineLoosening.push(`global cap increased from ${baseCap} to ${cap}`);
|
||||
}
|
||||
const baseEntries = new Map(
|
||||
(baseBaseline.godfileDebt ?? []).map((entry) => [entry.path, entry.maxLines]),
|
||||
);
|
||||
for (const [debtPath, maxLines] of debtEntries) {
|
||||
const priorMax = baseEntries.get(debtPath);
|
||||
if (priorMax === undefined) {
|
||||
baselineLoosening.push(`new debt entry: ${debtPath}`);
|
||||
} else if (maxLines > priorMax) {
|
||||
baselineLoosening.push(`cap increased for ${debtPath}: ${priorMax} -> ${maxLines}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cap,
|
||||
newDebt,
|
||||
grownDebt,
|
||||
stale,
|
||||
remaining,
|
||||
baselineLoosening,
|
||||
comparedBaseline: Boolean(baseBaseline),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
for (const candidate of ["origin/dev", "origin/main"]) {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", candidate], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
});
|
||||
return candidate;
|
||||
} catch {
|
||||
// candidate ref not present locally; try the next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function changedGovernedFiles(baseRef) {
|
||||
let mergeBase;
|
||||
try {
|
||||
mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const diffed = gitLines(["diff", "--name-only", "--diff-filter=AMR", mergeBase, "HEAD"]);
|
||||
const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]);
|
||||
return [...new Set([...diffed, ...untracked])].filter(isGoverned);
|
||||
}
|
||||
|
||||
function maintainabilityGate(files) {
|
||||
if (files.length === 0) {
|
||||
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||
}
|
||||
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
|
||||
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||
const result = spawnSync(
|
||||
eslintBin,
|
||||
[
|
||||
...files,
|
||||
...ruleArgs,
|
||||
"--max-warnings=0",
|
||||
"--no-warn-ignored",
|
||||
"--no-error-on-unmatched-pattern",
|
||||
],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
);
|
||||
return {
|
||||
skipped: false,
|
||||
status: result.status,
|
||||
stdout: result.stdout?.trim() ?? "",
|
||||
stderr: result.stderr?.trim() ?? "",
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function plural(count, word) {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
if (!baseRef) {
|
||||
failures.push(
|
||||
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
gitText(["merge-base", baseRef, "HEAD"]);
|
||||
} catch {
|
||||
failures.push(
|
||||
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log("godfile ratchet: legacy caps may only shrink");
|
||||
const god = godfileRatchet(baseRef);
|
||||
console.log(
|
||||
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
|
||||
);
|
||||
for (const entry of god.remaining) {
|
||||
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.newDebt) {
|
||||
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
|
||||
}
|
||||
for (const entry of god.grownDebt) {
|
||||
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
|
||||
}
|
||||
if (god.newDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
|
||||
);
|
||||
}
|
||||
if (god.grownDebt.length > 0) {
|
||||
failures.push(
|
||||
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
|
||||
);
|
||||
}
|
||||
if (god.baselineLoosening.length > 0) {
|
||||
failures.push(
|
||||
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
|
||||
);
|
||||
}
|
||||
if (!god.comparedBaseline) {
|
||||
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
|
||||
}
|
||||
if (god.stale.length > 0) {
|
||||
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
|
||||
for (const entry of god.stale) {
|
||||
console.log(` stale ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (!baseRef) {
|
||||
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
|
||||
} else {
|
||||
const files = changedGovernedFiles(baseRef);
|
||||
console.log(
|
||||
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
|
||||
);
|
||||
if (files === null) {
|
||||
console.log(" failed — base ref could not be resolved against HEAD");
|
||||
} else {
|
||||
const gate = maintainabilityGate(files);
|
||||
if (gate.skipped) {
|
||||
console.log(` skipped — ${gate.reason}`);
|
||||
} else {
|
||||
const clean = gate.status === 0;
|
||||
console.log(
|
||||
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
|
||||
);
|
||||
if (!clean) {
|
||||
if (gate.stdout) console.log(gate.stdout);
|
||||
if (gate.stderr) console.log(gate.stderr);
|
||||
failures.push(
|
||||
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
for (const failure of failures) console.log(` - ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("RESULT: PASS — all governance gates green");
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
||||
|
|
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
|||
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
|
||||
return (
|
||||
<div className={cn(className)} {...props}>
|
||||
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<Text variant="title" className="m-0 text-center font-bold text-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="description"
|
||||
tone="muted"
|
||||
when={Boolean(subtitle)}
|
||||
className="mb-6! mt-1.5! text-center font-normal"
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@ export function LoginForm({
|
|||
disabled={isLoggingIn}
|
||||
error={Boolean(errors.password)}
|
||||
/>
|
||||
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
|
||||
{Boolean(loginError) && (
|
||||
<FormErrorMessage message="Invalid email or password. Please try again." />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
|||
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||
import { LoginForm } from "@/app/(auth)/_components/login-form";
|
||||
import { BrandLockup } from "@/components/common/brand-lockup";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
|
|
@ -37,9 +38,13 @@ export default function LoginPage() {
|
|||
isLoggingIn={isLoggingIn}
|
||||
loginError={loginError}
|
||||
/>
|
||||
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
|
||||
<Text
|
||||
variant="caption"
|
||||
tone="muted"
|
||||
className="mt-5! block text-center text-(length:--text-xs)"
|
||||
>
|
||||
Having trouble? Contact your administrator.
|
||||
</p>
|
||||
</Text>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export default function AccountFormPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load account"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export default function AccountsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load accounts"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export default function AssetFormPage() {
|
|||
<Stack spacing={3}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
|
||||
|
||||
{(error || optionsError) && (
|
||||
{Boolean(error || optionsError) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export default function AssetsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load assets"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export default function EventFormPage() {
|
|||
<Stack spacing={3}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
|
||||
|
||||
{error && <Alert severity="error">Failed to load event</Alert>}
|
||||
{Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export default function ContactFormPage() {
|
|||
<Stack spacing={4}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load contact"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default function ContactsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load contacts"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
|||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
{Boolean(description) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
|
|
@ -106,7 +106,7 @@ export default function DashboardPage() {
|
|||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function SelectField({
|
|||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && (
|
||||
{Boolean(helperText) && (
|
||||
<Typography variant="caption" color="error" className="mt-1">
|
||||
{helperText}
|
||||
</Typography>
|
||||
|
|
@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load employee"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function EmployeesListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load employees"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
|
|||
<Stack spacing={4}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load follow-up"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
|||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
{Boolean(description) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
|
|
@ -106,7 +106,7 @@ export default function DashboardPage() {
|
|||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export default function LocationFormPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load location"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export default function LocationsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
|
|||
<Stack spacing={3}>
|
||||
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
||||
|
||||
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
|
||||
{optionsError && (
|
||||
{Boolean(error) && <Alert severity="error">Failed to load PM schedule</Alert>}
|
||||
{Boolean(optionsError) && (
|
||||
<Alert severity="error">
|
||||
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
|
|||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load PM schedules"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
|
|||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{parentCategory && (
|
||||
{Boolean(parentCategory) && (
|
||||
<FormControl size="small" className="min-w-[160px]">
|
||||
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
|
||||
<Select
|
||||
|
|
@ -201,7 +201,7 @@ export default function DropdownOptionsPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load options"}
|
||||
</Alert>
|
||||
|
|
@ -262,7 +262,7 @@ export default function DropdownOptionsPage() {
|
|||
<Typography variant="body2" className="truncate">
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.parentValue && (
|
||||
{Boolean(item.parentValue) && (
|
||||
<Chip size="small" label={item.parentValue} variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export default function TaskTemplatesPage() {
|
|||
Create reusable checklists for work order dispatches
|
||||
</Typography>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error" className="mb-4">
|
||||
{error instanceof Error ? error.message : "Failed to load templates"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
|
|||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
|
|
@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
|
|||
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{row.workOrderId && (
|
||||
{Boolean(row.workOrderId) && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export default function VendorPoViewPage() {
|
|||
Dispatch {dispatch.dispatchNumber || "—"}
|
||||
</Typography>
|
||||
<Chip size="small" label={dispatch.status || "—"} />
|
||||
{vendor?.companyName && (
|
||||
{vendor != null && Boolean(vendor.companyName) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
· {vendor.companyName}
|
||||
</Typography>
|
||||
|
|
@ -177,7 +177,7 @@ export default function VendorPoViewPage() {
|
|||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||
</Box>
|
||||
{dispatch.description && (
|
||||
{Boolean(dispatch.description) && (
|
||||
<Box className="mt-3">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Description
|
||||
|
|
@ -189,7 +189,7 @@ export default function VendorPoViewPage() {
|
|||
)}
|
||||
</Paper>
|
||||
|
||||
{dispatch.primaryLocation && (
|
||||
{dispatch.primaryLocation != null && (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Site
|
||||
|
|
@ -277,7 +277,7 @@ export default function VendorPoViewPage() {
|
|||
</Typography>
|
||||
<Chip size="small" label={u.status} />
|
||||
</Stack>
|
||||
{u.vendorReason && (
|
||||
{Boolean(u.vendorReason) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {u.vendorReason}
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export default function VendorPosListPage() {
|
|||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
{error && (
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
24
src/app/(protected)/vendors/index.tsx
vendored
24
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -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>
|
||||
|
|
|
|||
2
src/app/(protected)/vendors/insights.tsx
vendored
2
src/app/(protected)/vendors/insights.tsx
vendored
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
184
src/app/(protected)/vendors/preferences.tsx
vendored
184
src/app/(protected)/vendors/preferences.tsx
vendored
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export function DispatchCreateModal({
|
|||
<Typography variant="body2" className="font-semibold">
|
||||
{vendor.companyName}
|
||||
</Typography>
|
||||
{vendor.tradeSpecialties && (
|
||||
{Boolean(vendor.tradeSpecialties) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
|
|
@ -253,7 +253,7 @@ export function DispatchCreateModal({
|
|||
{vendor.tradeSpecialties}
|
||||
</Typography>
|
||||
)}
|
||||
{vendor.address && (
|
||||
{Boolean(vendor.address) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ export function DispatchDetailModal({
|
|||
}
|
||||
/>
|
||||
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
||||
{dispatch.description && (
|
||||
{Boolean(dispatch.description) && (
|
||||
<DetailField
|
||||
label="Description"
|
||||
value={dispatch.description}
|
||||
|
|
@ -448,7 +448,7 @@ export function DispatchDetailModal({
|
|||
<Typography variant="subtitle2">{type}</Typography>
|
||||
{signoff ? (
|
||||
<Box className="mt-2">
|
||||
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
||||
{signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
|
||||
<img
|
||||
src={signoff.signature}
|
||||
alt="Signature"
|
||||
|
|
@ -527,12 +527,12 @@ export function DispatchDetailModal({
|
|||
{Number(uplift.delta).toFixed(2)})
|
||||
</Typography>
|
||||
</Stack>
|
||||
{uplift.vendorReason && (
|
||||
{Boolean(uplift.vendorReason) && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
{Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="mt-1"
|
||||
|
|
@ -545,9 +545,11 @@ export function DispatchDetailModal({
|
|||
</Typography>
|
||||
)}
|
||||
{uplift.status !== "Pending" &&
|
||||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
Boolean(
|
||||
uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
|
||||
) && (
|
||||
<Box className="mt-1">
|
||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
||||
{Boolean(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
|
|
@ -558,7 +560,7 @@ export function DispatchDetailModal({
|
|||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.decisionNote && (
|
||||
{Boolean(uplift.decisionNote) && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
|
|
@ -720,7 +722,7 @@ export function DispatchDetailModal({
|
|||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{sigCaptureType && (
|
||||
{sigCaptureType != null && (
|
||||
<SignatureCapture
|
||||
title={`${sigCaptureType} Sign-off`}
|
||||
onClose={() => setSigCaptureType(null)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
|
||||
export type ReturnTypeOfUseWorkOrdersTableData = ReturnType<typeof useWorkOrdersTableData>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)",
|
||||
};
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
319
src/app/(protected)/workorders/_components/work-order-fields.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_OPTIONS,
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type LocationOption,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common";
|
||||
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text variant="label" className="mb-3 font-semibold uppercase tracking-wide">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type FormFieldHandlers = {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
control: Control<WorkOrderFormSchemaValues>;
|
||||
errors: FieldErrors<WorkOrderFormSchemaValues>;
|
||||
};
|
||||
|
||||
type WorkOrderDetailsFieldsProps = FormFieldHandlers & {
|
||||
locationOptions: LocationOption[];
|
||||
isLoadingLocations: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrderDetailsFields({
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
locationOptions,
|
||||
isLoadingLocations,
|
||||
}: WorkOrderDetailsFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Work Order Details</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("workOrderNumber")}
|
||||
label="Customer WO"
|
||||
placeholder="Customer work order number"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("title")}
|
||||
label="Title"
|
||||
required
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
|
||||
<Controller
|
||||
name="locationId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={locationOptions}
|
||||
loading={isLoadingLocations}
|
||||
getOptionLabel={(option: LocationOption) => option.name}
|
||||
value={
|
||||
locationOptions.find((location) => String(location.id) === field.value) ?? null
|
||||
}
|
||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Location"
|
||||
required
|
||||
error={Boolean(errors.locationId)}
|
||||
helperText={errors.locationId?.message}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("source")}
|
||||
label="Source"
|
||||
placeholder="e.g., Amazon APM, Manual"
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ClassificationFieldsProps = {
|
||||
control: Control<WorkOrderFormSchemaValues>;
|
||||
problems: DropdownOption[];
|
||||
trades: DropdownOption[];
|
||||
subTrades: DropdownOption[];
|
||||
tradeValue: string;
|
||||
};
|
||||
|
||||
export function ClassificationFields({
|
||||
control,
|
||||
problems,
|
||||
trades,
|
||||
subTrades,
|
||||
tradeValue,
|
||||
}: ClassificationFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Classification</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<Controller
|
||||
name="problem"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} value={field.value ?? ""} label="Problem" select fullWidth>
|
||||
<MenuItem value="">Select Problem</MenuItem>
|
||||
{problems.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="trade"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} value={field.value ?? ""} label="Trade" select fullWidth>
|
||||
<MenuItem value="">Select Trade</MenuItem>
|
||||
{trades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="subTrade"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
label="Sub-Trade"
|
||||
select
|
||||
disabled={!tradeValue}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
|
||||
</MenuItem>
|
||||
{subTrades.map((option) => (
|
||||
<MenuItem key={String(option.id)} value={option.value}>
|
||||
{option.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleFieldsProps = FormFieldHandlers & {
|
||||
employees: EmployeeOption[];
|
||||
};
|
||||
|
||||
export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Schedule & Priority</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<Controller
|
||||
name="priority"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
label="Priority"
|
||||
select
|
||||
required
|
||||
error={Boolean(errors.priority)}
|
||||
helperText={errors.priority?.message}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">Select Priority</MenuItem>
|
||||
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
|
||||
<MenuItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? "Open"}
|
||||
label="Status"
|
||||
select
|
||||
required
|
||||
fullWidth
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="assignedTo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={employees.map((employee) => employee.name)}
|
||||
value={field.value}
|
||||
onChange={(_event, value) => field.onChange(value ?? "")}
|
||||
onInputChange={(_event, value) => field.onChange(value)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
|
||||
<TextField
|
||||
{...register("createdDate")}
|
||||
label="Created"
|
||||
type="date"
|
||||
required
|
||||
error={Boolean(errors.createdDate)}
|
||||
helperText={errors.createdDate?.message}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("dueDate")}
|
||||
label="Due Date"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("scheduledDate")}
|
||||
label="Scheduled"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("completedDate")}
|
||||
label="Completed"
|
||||
type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinancialFields({
|
||||
register,
|
||||
}: {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Financial</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { step: "0.01", min: 0 } }}
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField {...register("tt")} label="TT" placeholder="Trouble ticket number" fullWidth />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DescriptionFields({
|
||||
register,
|
||||
}: {
|
||||
register: UseFormRegister<WorkOrderFormSchemaValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Description</SectionTitle>
|
||||
<TextField
|
||||
{...register("description")}
|
||||
label="Description"
|
||||
multiline
|
||||
rows={8}
|
||||
placeholder="Detailed description of the work to be performed"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,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>
|
||||
|
|
|
|||
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal file
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue