diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b220720..ab2cfb56 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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/) + # 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2803356c --- /dev/null +++ b/AGENTS.md @@ -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 ? : 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 `

`/`` 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). diff --git a/ARCHITECTURE_AND_CODE_QUALITY.md b/ARCHITECTURE_AND_CODE_QUALITY.md new file mode 100644 index 00000000..3cd2aefc --- /dev/null +++ b/ARCHITECTURE_AND_CODE_QUALITY.md @@ -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 ? : 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 && }` 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 `

` and + `

`–`

` 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. diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md new file mode 100644 index 00000000..b28babd0 --- /dev/null +++ b/QUALITY_GATES.md @@ -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`. diff --git a/README.md b/README.md index e380b5df..51fc21d2 100644 --- a/README.md +++ b/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`) diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md new file mode 100644 index 00000000..977582f4 --- /dev/null +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -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. diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md new file mode 100644 index 00000000..9b125ecc --- /dev/null +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -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 && {error.message}; +} + + + {description} +; +``` + +ESLint rejects `condition ? : 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 && }` 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) && }` and `{Boolean(description) && }` 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 && }` 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. `
`, `
`); 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. diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 4d2b4e38..20799b49 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -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 | 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; + 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, }) => { diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js new file mode 100644 index 00000000..10408424 --- /dev/null +++ b/eslint-rules/no-non-boolean-jsx-and.js @@ -0,0 +1,95 @@ +import ts from "typescript"; + +/** + * Local type-aware rule: the left operand of `&&` in JSX must be entirely + * boolean. + * + * `{value && }` renders its left operand when it is falsy and + * non-boolean (notably `{count && }` 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; diff --git a/eslint-rules/no-vp-error-outside-text.js b/eslint-rules/no-vp-error-outside-text.js new file mode 100644 index 00000000..3498f5ea --- /dev/null +++ b/eslint-rules/no-vp-error-outside-text.js @@ -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; diff --git a/eslint.config.js b/eslint.config.js index 8d1f29c2..10c0aa46 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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.", + }, + ], }, }, { diff --git a/package.json b/package.json index ee9bb861..3df2f4da 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json new file mode 100644 index 00000000..d82858fd --- /dev/null +++ b/scripts/governance-baseline.json @@ -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." + } + ] +} diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs new file mode 100644 index 00000000..41bb1de9 --- /dev/null +++ b/scripts/governance-check.mjs @@ -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(); diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx index 795ca0a7..aaf95cf6 100644 --- a/src/app/(auth)/_components/auth-card-header.tsx +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -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 (
-

{title}

- {subtitle && ( -

- {subtitle} -

- )} + + {title} + + + {subtitle} +
); } diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx index e06ba713..3e3d8456 100644 --- a/src/app/(auth)/_components/login-form.tsx +++ b/src/app/(auth)/_components/login-form.tsx @@ -75,7 +75,9 @@ export function LoginForm({ disabled={isLoggingIn} error={Boolean(errors.password)} /> - {loginError && } + {Boolean(loginError) && ( + + )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load account"} diff --git a/src/app/(protected)/accounts/index.tsx b/src/app/(protected)/accounts/index.tsx index f4aea5da..7697c764 100644 --- a/src/app/(protected)/accounts/index.tsx +++ b/src/app/(protected)/accounts/index.tsx @@ -118,7 +118,7 @@ export default function AccountsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load accounts"} diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx index d01ae284..513f1837 100644 --- a/src/app/(protected)/assets/_components/asset-form.tsx +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -91,7 +91,7 @@ export default function AssetFormPage() { {isEdit ? "Edit Asset" : "Create Asset"} - {(error || optionsError) && ( + {Boolean(error || optionsError) && ( {error instanceof Error ? error.message diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx index bea3dd35..e6d6451f 100644 --- a/src/app/(protected)/assets/index.tsx +++ b/src/app/(protected)/assets/index.tsx @@ -116,7 +116,7 @@ export default function AssetsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load assets"} diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx index 183201d2..01134222 100644 --- a/src/app/(protected)/calendar/_components/event-form.tsx +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -120,7 +120,7 @@ export default function EventFormPage() { {isEdit ? "Edit Event" : "Create Event"} - {error && Failed to load event} + {Boolean(error) && Failed to load event} diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx index f2fefc39..401265d6 100644 --- a/src/app/(protected)/contacts/_components/contact-form.tsx +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -127,7 +127,7 @@ export default function ContactFormPage() { {isEdit ? "Edit Contact" : "Create Contact"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contact"} diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx index 54591c7e..e910c8f4 100644 --- a/src/app/(protected)/contacts/index.tsx +++ b/src/app/(protected)/contacts/index.tsx @@ -103,7 +103,7 @@ export default function ContactsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contacts"} diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/dashboard.tsx +++ b/src/app/(protected)/dashboard.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx index 78ed8bc1..6d5413a8 100644 --- a/src/app/(protected)/employees/_components/employee-form.tsx +++ b/src/app/(protected)/employees/_components/employee-form.tsx @@ -83,7 +83,7 @@ function SelectField({ ))} - {helperText && ( + {Boolean(helperText) && ( {helperText} @@ -193,7 +193,7 @@ export default function EmployeeFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employee"} diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx index b570dbe3..ed56887b 100644 --- a/src/app/(protected)/employees/index.tsx +++ b/src/app/(protected)/employees/index.tsx @@ -124,7 +124,7 @@ export default function EmployeesListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employees"} diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx index 603dd3f1..d7a6a86c 100644 --- a/src/app/(protected)/followups/_components/follow-up-form.tsx +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -102,7 +102,7 @@ export default function FollowUpFormPage() { {isEdit ? "Edit Follow-up" : "Create Follow-up"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-up"} diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx index 69b42a59..30980fc0 100644 --- a/src/app/(protected)/followups/index.tsx +++ b/src/app/(protected)/followups/index.tsx @@ -129,7 +129,7 @@ export default function FollowUpsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-ups"} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 79ef1acc..990fc75d 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -114,7 +114,7 @@ export default function LocationFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load location"} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index df239574..097db2c8 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -118,7 +118,7 @@ export default function LocationsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load locations"} diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx index 29c18cf6..c4e0b75e 100644 --- a/src/app/(protected)/notifications/index.tsx +++ b/src/app/(protected)/notifications/index.tsx @@ -42,7 +42,7 @@ export default function NotificationsPage() { {isLoading && } - {error && {error.message}} + {Boolean(error) && {error?.message}} {!isLoading && !error && data.length === 0 && ( No vendor alerts require attention. @@ -70,14 +70,14 @@ export default function NotificationsPage() { {item.workOrderNumber ?? item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} - {item.scheduledAt && ( + {item.scheduledAt != null && ( Scheduled {new Date(item.scheduledAt).toLocaleString()} )} - {item.action && ( + {item.action != null && ( diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx index d3b82f15..0253a149 100644 --- a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -80,8 +80,8 @@ export default function PmScheduleFormPage() { {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} - {error && Failed to load PM schedule} - {optionsError && ( + {Boolean(error) && Failed to load PM schedule} + {Boolean(optionsError) && ( {optionsError instanceof Error ? optionsError.message : "Failed to load form options"} diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx index 4517d4ed..826381e7 100644 --- a/src/app/(protected)/pmschedules/index.tsx +++ b/src/app/(protected)/pmschedules/index.tsx @@ -122,7 +122,7 @@ export default function PmSchedulesListPage() { )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load PM schedules"} diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx index 4e2c0df7..e4aef562 100644 --- a/src/app/(protected)/settings/dropdowns.tsx +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -158,7 +158,7 @@ export default function DropdownOptionsPage() { alignItems: "stretch", }} > - {parentCategory && ( + {Boolean(parentCategory) && ( Parent Trade 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" + /> +
+
+ {filtered.map((d) => ( + + ))} +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx new file mode 100644 index 00000000..43f258c3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx @@ -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 ( + + ); + } + + 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 ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx new file mode 100644 index 00000000..ae377351 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -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(null); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + ); + const showWeek = allowWeekMode && !value && !!targetWeek; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} + > + {Boolean(allowWeekMode) && ( +
+ {(["week", "date"] as const).map((m) => { + const active = cellMode === m; + return ( + + ); + })} +
+ )} + {allowWeekMode && cellMode === "week" ? ( + { + onSave(""); + onSaveWeek?.(mon); + setAnchorEl(null); + }} + /> + ) : ( + { + onSave(d); + setAnchorEl(null); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx new file mode 100644 index 00000000..77ac64e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx new file mode 100644 index 00000000..ce4b3462 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -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 ( + + {count} + + ); +} + +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 ( +
+ {editing ? ( + 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, + }} + /> + ) : ( + { + 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 ? : "-"} + + )} + + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx new file mode 100644 index 00000000..cda95c32 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -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(null); + + if (disabled) { + return ( + + + + ); + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }} + > +
+ {FLAG_COLORS.map((c) => ( + + ))} +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx new file mode 100644 index 00000000..95b10c15 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx @@ -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} + {match} + {after} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx new file mode 100644 index 00000000..69227086 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx @@ -0,0 +1,11 @@ +import { ChevronDown } from "lucide-react"; + +export function HoverChevron() { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx new file mode 100644 index 00000000..4ae184df --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -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(null); + + const [search, setSearch] = useState(""); + + const filtered = search + ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : pmTypes; + + return ( + <> + + + { + 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" } }} + > +
+ 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" + /> +
+ +
+ {filtered.map((pm) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx new file mode 100644 index 00000000..6cb860b8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -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 ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx new file mode 100644 index 00000000..1dc0d678 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -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 ( + + ); +} + +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 ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "w-full", + style: { borderRadius: 12, maxWidth: 480 }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + Site + + + Select a site and edit its point of contact + + +
+
+ Site + + } + searchPlaceholder="Search site code or city…" + options={sites.map((site) => ({ + key: String(site.id), + label: site.name, + }))} + onPick={handlePick} + /> +
+ + {locationDetail != null && Boolean(locId) && ( +
+ + {locationDetail.phone != null && } + {locationDetail.contactEmail != null && ( + + )} + {Boolean(locationDetail.address) && ( + + Open in Google Maps + + )} +
+ )} + +
+ Point of Contact + + Auto-filled from records — edit if outdated + +
+ setPn(e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + setPp(e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +