fix(work-orders): persist additionalContacts on create and board PATCH [SH-218]

This commit is contained in:
Arthur Bassi 2026-08-10 13:54:40 -03:00
commit 3f372257dc
643 changed files with 61758 additions and 0 deletions

View file

@ -0,0 +1,52 @@
---
description: SeaHaven Admin — IrisLoan architecture, selective rebuild conventions
alwaysApply: true
---
# SeaHaven Admin — IrisLoan conventions
## Stack
- Vite SPA + React Router 7, TypeScript strict, Tailwind v4 + MUI
- HTTP: Ky with `credentials: "include"` (httpOnly cookies)
- Server state: TanStack Query; forms: React Hook Form + Zod
- **Do not use** in new code: Next.js, Redux, axios, `apiUtil.js`, `tokenUtility.js`
## Target structure (`src/`)
```
api/ domain/<feature>/ app/ infra/query-key/ lib/ hooks/
components/ui|common|layout/ config/menu.ts providers/ main.tsx
```
- `@/` alias for imports; new files in **kebab-case** (`use-debounce.ts`, `query-key.ts`)
- `pages/` is **reference only** for endpoints and business rules — delete after delivery in `domain/` + `app/`
## HTTP and auth
- Centralize paths in `api/api-paths.ts` and responses in `api/handle-api-response.ts`
- Auth via AuthProvider + cookies; do not create Redux slices for session
## State and data
- Cache and mutations: React Query + `infra/query-key/query-key.ts`
- Client defaults in `lib/query/query-client.ts`
## Forms and UI
- Zod schemas in `domain/<feature>/schemas/`; thin views in `app/`
- Utility Tailwind + MUI components; **do not** import `site.css` in new code
## Feature implementation flow
1. Consult legacy in `pages/<feature>/` (endpoints, fields, rules)
2. Create `domain/<feature>/` (schemas, hooks, use-cases)
3. Thin view in `app/` + route in `app/routes.tsx`
4. Delete `pages/<feature>/` when the new feature is ready
## Tooling
- Strict ESLint only on new architecture `.ts/.tsx`; legacy in `pages/` ignored
- Commits: Conventional Commits (`feat(scope): subject`)
Reference: `docs/ARCHITECTURE_PLAN.md`

1
.env Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=

5
.env.development Normal file
View file

@ -0,0 +1,5 @@
# Development API URL (uses proxy)
VITE_API_URL=/api
# Development API Target (for proxy)
VITE_API_TARGET=http://localhost:5141

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# API Configuration
# Copy this file to .env and update with your actual values
# Backend API base URL (Ky prefixUrl). API route paths do NOT include /api — it comes from here.
# Development: use /api (Vite proxies /api to VITE_API_TARGET)
#VITE_API_URL=/api
# Production: absolute URLs MUST include the /api path segment
VITE_API_URL=https://api.seahavenindustries.com/api
# Development proxy target (used by vite.config.ts)
#VITE_API_TARGET=http://localhost:5141

4
.env.production Normal file
View file

@ -0,0 +1,4 @@
# Production API base — the SPA calls the backend directly over HTTPS.
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
VITE_API_URL=https://api.dev.seahaven.com/api

3
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,3 @@
# All changes require an approving review from the internal-dev team.
# Enforced via the org "main branch protection" ruleset (require_code_owner_review).
* @Sea-Haven-Industries/internal-dev

43
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,43 @@
name: CI
on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]
permissions:
contents: read
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

38
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Deploy
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
#
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
# CloudFront. Both run as the OIDC deploy role created by the stack.
#
# When staging/prod accounts exist, add jobs keyed to their branches and their
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
on:
push:
branches: [dev]
workflow_dispatch: {}
# OIDC needs id-token: write — it is never in the default token set and cannot
# be granted to the reusable workflow unless the caller has it.
permissions:
id-token: write
contents: read
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
with:
node-version: "24"
region: us-east-1
cdk-dir: infra/cdk
stack-name: shoc-frontend-dev
post-deploy-script: scripts/deploy-web.sh
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}

48
.gitignore vendored Normal file
View file

@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/clone-legacy
/.pnp
.pnp.js
# testing
/coverage
/test-results
/playwright-report
/blob-report
/playwright/.cache
# production
/dist
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
db.txt
seed-data.sql
/backend
# eslint
.eslintcache
# typescript
*.tsbuildinfo
# cdk (infra/cdk)
infra/cdk/node_modules
infra/cdk/cdk.out
infra/cdk/cdk.context.json
infra/cdk/*.d.ts
infra/cdk/bin/*.d.ts
infra/cdk/bin/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js

1
.husky/commit-msg Normal file
View file

@ -0,0 +1 @@
npx commitlint --edit $1

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
npx lint-staged

13
.prettierignore Normal file
View file

@ -0,0 +1,13 @@
dist
build
node_modules
coverage
package-lock.json
*.tsbuildinfo
# Legacy — format incrementally when touched (lint-staged)
src/pages/**
src/**/*.js
src/**/*.jsx
src/**/*.css
!src/styles/globals.css

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"overrides": [
{
"files": "*.mdc",
"options": {
"parser": "markdown"
}
}
]
}

81
AGENTS.md Normal file
View file

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

View file

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

54
QUALITY_GATES.md Normal file
View file

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

188
README.md Normal file
View file

@ -0,0 +1,188 @@
# SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white)
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
portal, uplifts, and related admin features. This is the selective rebuild of
the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
```mermaid
graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
ROLE -->|cdk deploy + s3 sync + invalidation| S3
```
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
architecture plan for the keep/discard migration matrix).
## AWS Resources
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
No Lambdas, queues, or databases — this stack is static hosting only.
## Configuration
### Secrets
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
repo secret**:
| Secret | Purpose |
| --------------------- | ----------------------------------------------------------------------------------- |
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
### Environment variables (build-time, `VITE_*`)
| Variable | Description | Dev value |
| ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `VITE_API_URL` | Ky API base prefix, **baked into the build** at `vite build` | `/api` (dev server) / `https://api.dev.seahaven.com/api` (production build) |
| `VITE_API_TARGET` | Dev-proxy target for `/api` (Vite dev server only) | `http://localhost:5141` |
`VITE_API_URL` supplies the full API prefix — route paths in `API_PATHS` do
**not** include `/api`. Absolute values **must end with `/api`**;
`vite.config.ts` enforces this via `config/api-url-contract.ts` and fails the
build otherwise. See [`.env.example`](.env.example),
[`.env.development`](.env.development), and [`.env.production`](.env.production).
CDK context (domain, certificate ARN, hosted zone) lives in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
## Local Development
Requirements: Node.js ≥ 22.22.1 (CI/CD run Node 24), npm 11.16.0 (pinned via
`packageManager`).
```bash
npm ci
cp .env.example .env # then set VITE_API_URL=/api for local dev
npm run dev # Vite dev server on port 3000, proxies /api → VITE_API_TARGET
```
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) |
| `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
`npm run build`) before pushing to catch type errors early.
## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time.
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
a green CI run and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
exists yet).
## Deployment
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; **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`)
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
CloudFront invalidation. Both run as the OIDC deploy role.
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
[`infra/cdk/README.md`](infra/cdk/README.md).
Manual deploy (emergency/reference only — needs credentials for the
external-dev AWS account; the normal path is push to `dev`):
```bash
(cd infra/cdk && npx cdk deploy)
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
## Operations
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
directly and API calls succeed.
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
are no CloudWatch application logs — the stack is static hosting; runtime
errors surface in the browser and on the backend API's side.
- **Common failure modes:**
- _Stale content after deploy_ — the CloudFront invalidation step failed or
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
on this repo; deploys from other branches/repos are rejected by design.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`.
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
deploys (matches the org's push-time-CD model; gating deploy on CI is known
follow-up work).
## Documentation
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/)

View file

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

1
commitlint.config.js Normal file
View file

@ -0,0 +1 @@
export default { extends: ["@commitlint/config-conventional"] };

View file

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { assertAbsoluteApiUrlHasApiSegment } from "./api-url-contract";
describe("assertAbsoluteApiUrlHasApiSegment", () => {
it("allows relative /api", () => {
expect(() => assertAbsoluteApiUrlHasApiSegment("/api")).not.toThrow();
});
it("allows empty or undefined", () => {
expect(() => assertAbsoluteApiUrlHasApiSegment(undefined)).not.toThrow();
expect(() => assertAbsoluteApiUrlHasApiSegment("")).not.toThrow();
});
it("allows absolute URL ending with /api", () => {
expect(() =>
assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com/api"),
).not.toThrow();
});
it("rejects absolute URL without /api", () => {
expect(() => assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com")).toThrow(
/must end with \/api/,
);
});
});
describe(".env.example contract", () => {
it("documents an absolute VITE_API_URL that includes /api", () => {
const envExample = readFileSync(path.resolve(process.cwd(), ".env.example"), "utf8");
const match = envExample.match(/^VITE_API_URL=(.+)$/m);
expect(match).not.toBeNull();
const value = match![1]!.trim();
if (value.startsWith("http")) {
expect(() => assertAbsoluteApiUrlHasApiSegment(value)).not.toThrow();
}
});
});

View file

@ -0,0 +1,21 @@
/** Absolute VITE_API_URL values must end with this segment (API_PATHS omit /api). */
export const ABSOLUTE_API_URL_SUFFIX = /\/api\/?$/;
/**
* Fails fast when an absolute VITE_API_URL omits the /api segment.
* Relative values (e.g. /api) are valid and skipped.
*/
export function assertAbsoluteApiUrlHasApiSegment(apiUrl: string | undefined): void {
const trimmed = apiUrl?.trim();
if (!trimmed?.startsWith("http")) {
return;
}
if (!ABSOLUTE_API_URL_SUFFIX.test(trimmed)) {
throw new Error(
`VITE_API_URL absolute URLs must end with /api (got: ${trimmed}). ` +
"API route paths do not include /api — set VITE_API_URL to the full API base, e.g. https://api.example.com/api. " +
"See README.md and .env.example.",
);
}
}

View file

@ -0,0 +1,33 @@
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = path.resolve(__dirname, "..");
const manifest = JSON.parse(readFileSync(path.join(root, "public/manifest.json"), "utf-8")) as {
name: string;
short_name: string;
theme_color: string;
icons: Array<{ src: string }>;
};
const indexHtml = readFileSync(path.join(root, "index.html"), "utf-8");
describe("PWA install metadata", () => {
it("identifies the app as Sea Haven, not template branding", () => {
expect(manifest.name).toContain("Sea Haven");
expect(manifest.short_name).not.toBe("React App");
expect(manifest.name).not.toContain("Create React App");
});
it("uses the design-system theme color, matching index.html", () => {
expect(manifest.theme_color).toBe("#1c75bc");
expect(indexHtml).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
});
it("only references icon files that exist in public/", () => {
for (const icon of manifest.icons) {
expect(existsSync(path.join(root, "public", icon.src)), `missing public/${icon.src}`).toBe(
true,
);
}
});
});

102
docs/ARCHITECTURE_PLAN.md Normal file
View file

@ -0,0 +1,102 @@
# Architecture Plan — Sea Haven (Selective Rebuild)
> Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI
Mirror document of the Cursor plan. Strategy: **reuse knowledge, discard legacy code** — do not normalize/refactor the current project.
---
## Strategy in one sentence
Build `api/` + `domain/` + `app/` from scratch; use `pages/` only as **endpoint and business rule reference**; **delete** each legacy folder when the new feature is ready.
---
## Matrix: Keep vs Discard
### Keep (extract logic → new code)
| Legacy | Keep | Destination |
| ------------------------------------- | -------------------------------- | ------------------------------------------------ |
| `lib/api/services.js` | ASP.NET paths + response parsing | `api/api-paths.ts`, `api/handle-api-response.ts` |
| `pages/*/api.js` | Real endpoints | `api/api-paths.ts` |
| `constants/queryKeys.js` | Entities | `infra/query-key/query-key.ts` |
| `hooks/useDebounce.js`, `useModal.js` | Hooks | `hooks/*.ts` |
| `hooks/api/usePMSchedules.js` | RQ pattern | Template `domain/*/use-cases/` |
| `lib/queryClient.js` | Cache defaults | `lib/query/query-client.ts` |
| `App.js`, `Sidebar.js` | Routes and menu | `app/routes.tsx`, `config/menu.ts` |
| `pages/workorders/` (list) | Business flow | Reference for POC |
### Discard (delete without refactoring)
- `apiUtil.js`, `services/api.js`, `lib/api/client.js`
- `tokenUtility.js`, `authService.js`, Redux (`app/store.js`, slices)
- `usePaginatedList.js`, entire `pages/` folder (after replacement)
- `Site.Layout.js`, `setupProxy.js`, `site.css`, per-feature CSS, Font Awesome
- Forms `*FormPage.js` (rewrite with RHF + Zod)
- `SharedTable.js`, `ActionBar.js` (recreate if adapting is costly)
### Defer (later)
- `pages/vendor-portal/` → `domain/vendor-portal/`
- `pages/calendar/` → new FullCalendar wrapper
---
## Feature priority
Full matrix (waves, dependencies, LOC, done criteria): **[`FEATURE_PRIORITIZATION.md`](FEATURE_PRIORITIZATION.md)** · TS config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
| Wave | Scope |
| ----------------- | --------------------------------------------------------------------------- |
| **0 POC** | auth → work-orders (list) |
| **1 must-have** | work-orders (full), dashboard, settings/dropdowns |
| **2** | accounts, locations, employees |
| **3 should-have** | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
| **4 later** | calendar, vendor-portal, pm-schedules, assets, reports/documents |
Stakeholder decision: **auth + work-orders first**; POC = auth + work-orders list (not pm-schedules).
---
## Target structure
```
src/
├── api/
├── domain/<feature>/
├── infra/query-key/
├── lib/
├── components/ui/ + common/
├── app/
├── config/menu.ts
├── hooks/
├── providers/
└── main.tsx
```
---
## Phases
| Phase | Goal |
| ----- | ------------------------------------------------------------------------------------- |
| **0** | Complete foundation (Ky, routes, layout, AuthProvider, tooling) — Vite/TS/Tailwind ok |
| **1** | Ky + httpOnly auth cookies; delete legacy HTTP stacks |
| **2** | Rewrite must-have features; delete `pages/<feature>/` per delivery |
| **3** | UI shell + RHF+Zod forms |
| **4** | Remove `pages/`, Redux, legacy CSS; Docker + CI + Vitest |
---
## Next steps
1. Complete foundation (`api/api.ts`, `routes.tsx`, AuthProvider, layout shell)
2. `api/api-paths.ts` (extract from `lib/api/services.js`)
3. POC: `domain/auth` + login → delete `pages/auth/`
4. POC: `domain/work-orders` (list) → delete list portion of `pages/workorders/`
5. Cookie contract with backend
---
See full plan with diagrams in `.cursor/plans/migração_arquitetura_irisloan_cf39e1a6.plan.md`.

137
docs/DESIGN_SYSTEM.md Normal file
View file

@ -0,0 +1,137 @@
# Sea Haven Design System
Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`).
## Stack
- **MUI-only** — no Radix/shadcn; MUI is the sole component library
- **Tailwind v4** — semantic utility classes from CSS tokens
- **Fonts** — Montserrat (headings), DM Sans (body), JetBrains Mono (code) via `@fontsource`
## Token source of truth
| File | Purpose |
| --------------------------- | ------------------------------------------------------------ |
| `src/styles/theme.css` | All `:root` CSS variables + `@theme inline` Tailwind aliases |
| `src/styles/fonts.css` | `@fontsource` imports |
| `src/styles/typography.css` | Base `html`, `body`, `h1–h6`, `.text-caption`, `.text-label` |
| `src/styles/motion.css` | `--duration-fast`, `--duration-normal`, `--ease-default` |
| `src/styles/globals.css` | Entry: imports all above + `@custom-variant dark` |
## Key tokens
### Brand & surfaces
| Token | Value | Usage |
| -------------------- | --------- | -------------------------- |
| `--primary` | `#1c75bc` | Buttons, links, focus ring |
| `--primary-hover` | `#155a92` | Hover states |
| `--foreground` | `#262262` | Primary text |
| `--background` | `#ffffff` | Cards, inputs |
| `--color-bg-page` | `#f9fafb` | Page background |
| `--border` | `#dfe3ea` | Dividers, outlines |
| `--muted-foreground` | `#58595b` | Secondary text |
### Layout
| Token | Value |
| ----------------------------- | ------- |
| `--spacing-sidebar` | `220px` |
| `--spacing-sidebar-collapsed` | `56px` |
| `--spacing-topbar` | `64px` |
### Typography scale
| Token | Size |
| ---------------- | ---- |
| `--text-xs` | 11px |
| `--text-sm` | 12px |
| `--text-base-sm` | 13px |
| `--text-base` | 14px |
| `--text-md` | 16px |
| `--text-lg` | 18px |
| `--text-xl` | 22px |
| `--text-2xl` | 28px |
### Elevation
| Token | Usage |
| ------------- | ------------------- |
| `--shadow-sm` | Cards, subtle depth |
| `--shadow-md` | Dropdowns, panels |
| `--shadow-lg` | Dialogs, modals |
### Work order badges (immutable)
Type, status, and completion-doc tokens (`--type-*`, `--status-*`, `--doc-*`) must not be changed — see `src/components/domain/`.
## Typography hierarchy
| Element | Font | Weight | Size |
| --------------- | ---------- | ------ | -------------- |
| `h1` | Montserrat | 800 | 28px |
| `h2` | Montserrat | 700 | 22px |
| `h3` | Montserrat | 600 | 18px |
| `h4–h6` | Montserrat | 600 | 16–13px |
| Body | DM Sans | 400 | 14px |
| `.text-caption` | DM Sans | 400 | 12px |
| `.text-label` | DM Sans | 600 | 11px uppercase |
MUI typography variants in `src/lib/theme/mui-theme.ts` mirror this hierarchy.
## MUI + Tailwind usage
### Prefer themed MUI in forms and data tables
MUI components inherit Sea Haven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
### Prefer semantic Tailwind in layout shells
```tsx
<div className="bg-background text-foreground border-border" />
<div className="text-muted-foreground" />
<div className="bg-primary text-primary-foreground" />
```
### Compose classes with `cn()`
```tsx
import { cn } from "@/lib/utils";
<div className={cn("flex gap-2", isActive && "bg-sidebar-accent")} />;
```
### Shared UI wrappers
| Component | Path | Pattern |
| ------------ | ------------------------------- | ---------------------------- |
| `Button` | `components/ui/button.tsx` | MUI Button + variant presets |
| `Badge` | `components/ui/badge.tsx` | MUI Chip |
| `PageHeader` | `components/ui/page-header.tsx` | PATTERN_001 |
| `EmptyState` | `components/ui/empty-state.tsx` | PATTERN_003 |
| `FormField` | `components/ui/form-field.tsx` | PATTERN_004 |
### Domain badges
| Component | Path |
| -------------------------------------- | ------------------------------------ |
| `TypeBadge` | `components/domain/type-badge.tsx` |
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
| `DocBadge` | `components/domain/doc-badge.tsx` |
## Legacy → Sea Haven mapping
| Legacy | Sea Haven | Consumption |
| ------------------------- | ------------------------------ | -------------------------------- |
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
| `#f4f6f7` / `--bg` | `--color-bg-page` | Body, page shell |
| `#d9dde0` / `--border` | `--border` | Dividers, inputs |
| `#fbfbfb` sidebar | `--color-sidebar-bg` | App sidebar |
| Segoe UI 13px | DM Sans 14px | Body typography |
| Bootstrap calendar colors | `--primary`, `--success`, etc. | `calendar-utils.ts` |
| `#1a3a5c` login header | `--color-header-bg-start` | Login branding strip |
## Dark mode
`@custom-variant dark (&:is(.dark *))` is configured in `globals.css`. Full dark token set is a future enhancement; light mode is the current default.

View file

@ -0,0 +1,88 @@
# Design System Migration Log
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Sea Haven DS tokens. Strategy: **MUI-only**.
## Phase 1 — Foundation
| File | Action | Reason |
| --------------------------- | ---------- | --------------------------------------------------------------------- |
| `src/styles/theme.css` | Created | SoT: 131+ Sea Haven tokens + layout/typography/elevation extensions |
| `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` |
| `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles |
| `src/styles/motion.css` | Created | Duration and easing tokens |
| `src/styles/globals.css` | Refactored | Import chain + `@custom-variant dark`; removed legacy `@theme` tokens |
| `src/lib/utils.ts` | Created | `cn()` with clsx + tailwind-merge |
| `src/lib/theme/css-vars.ts` | Created | `getCssVar()` helper for MUI bridge |
| `package.json` | Modified | Added `@fontsource/*`, `clsx`, `tailwind-merge` |
## Phase 2 — MUI Bridge
| File | Action | Reason |
| ---------------------------- | -------- | --------------------------------------------------------------- |
| `src/lib/theme/mui-theme.ts` | Expanded | Full palette, typography, shape, shadows, 8 component overrides |
## Phase 3 — App Shell
| File | Action | Reason |
| ----------------------------------------- | -------- | -------------------------------------------------- |
| `src/components/layout/app-sidebar.tsx` | Created | MUI Drawer + sidebar tokens; replaces `Sidebar.js` |
| `src/components/layout/app-topbar.tsx` | Created | MUI AppBar + header gradient; replaces `Topbar.js` |
| `src/app/(protected)/_layout.tsx` | Modified | Uses new layout components |
| `src/main.tsx` | Modified | Removed `index.css` import |
| `src/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Sea Haven CSS vars |
| `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell |
| `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` |
| `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` |
| `src/styles/FormPage.css` | Deleted | Unused (no imports in codebase) |
## Phase 4 — Shared UI
| File | Action | Reason |
| ------------------------------------------ | -------- | -------------------------------------------- |
| `src/components/ui/button.tsx` | Created | MUI Button wrapper |
| `src/components/ui/badge.tsx` | Created | MUI Chip wrapper |
| `src/components/ui/page-header.tsx` | Created | PATTERN_001 |
| `src/components/ui/empty-state.tsx` | Created | PATTERN_003 |
| `src/components/ui/form-field.tsx` | Created | PATTERN_004 |
| `src/components/domain/type-badge.tsx` | Created | WO type tokens |
| `src/components/domain/status-badge.tsx` | Created | WO status tokens + API aliases |
| `src/components/domain/doc-badge.tsx` | Created | Completion doc tokens (MUI icons) |
| `src/app/(protected)/workorders/index.tsx` | Modified | `WorkOrderStatusBadge` replaces generic Chip |
## Phase 5 — Page refactor
| File | Action | Reason |
| ---------------------------------------------------------------------- | -------- | ------------------------------------------ |
| `src/app/(auth)/login.tsx` | Modified | `#1a3a5c` → `var(--color-header-bg-start)` |
| `src/app/(protected)/dashboard.tsx` | Modified | `border-gray-*` → `border-border` |
| `src/app/(protected)/index.tsx` | Modified | Same as dashboard |
| `src/app/(protected)/settings/dropdowns.tsx` | Modified | Semantic sidebar + active states |
| `src/app/(protected)/settings/task-templates.tsx` | Modified | Semantic borders/text |
| `src/components/common/settings-nav.tsx` | Modified | `text-primary`, `border-border` |
| `src/components/common/calendar/calendar-utils.ts` | Modified | Bootstrap hex → Sea Haven CSS vars |
| `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes |
| `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes |
| `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars |
| `src/app/(protected)/workorders/_components/dispatch-create-modal.tsx` | Modified | Semantic borders/hover |
| `src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx` | Modified | Semantic borders |
| `src/app/(protected)/workorders/[id].tsx` | Modified | Semantic borders |
| `src/app/(protected)/vendor-pos/[id].tsx` | Modified | Semantic borders |
## Phase 6 — Docs & validation
| File | Action | Reason |
| --------------------------------- | ------- | ------------------------------- |
| `docs/DESIGN_SYSTEM.md` | Created | Token reference and usage guide |
| `docs/DESIGN_SYSTEM_MIGRATION.md` | Created | This file |
## Visual impact summary
- Primary color: `#0c4f6f` → `#1c75bc` (expected, documented)
- Body font: Segoe UI 13px → DM Sans 14px
- Sidebar: `#fbfbfb` → `#f6f8fb` with Sea Haven active/hover states
- Header: flat blue gradient → Sea Haven multi-stop gradient
## Not migrated (intentional)
- CRUD list pages using default MUI theming — inherit via `mui-theme.ts` without JSX changes
- `src/app/v/_components/signature-pad.tsx` — vendor portal isolated; minor `#111` stroke remains

View file

@ -0,0 +1,160 @@
# Feature Prioritization Matrix — Sea Haven
> Source of truth for legacy → `domain/` + `app/` migration order.
> Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
---
## Stakeholder decisions
| Decision | Detail |
| ------------------------ | ----------------------------------------------------------------------------------------- |
| Top priority | **`auth`** and **`work-orders`** |
| End-to-end POC | **`auth` + work-orders (list)** — validate Ky, React Query, layout shell and AuthProvider |
| Other features | Flexible order, guided by technical dependencies (waves below) |
| POC **does not** include | `pm-schedules` (deferred to Wave 4) |
---
## Full matrix
| Order | Feature | Tier | Wave | Legacy folder | Routes | LOC ~ | Dependencies |
| ----- | ------------------------- | ----------- | ---- | ----------------------------------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------- |
| 1 | auth | POC | 0 | `pages/auth/` | `/login` | 513 | — |
| 2 | work-orders (list) | POC | 0 | `pages/workorders/` (list) | `/workorders` | 4.3k\* | auth |
| 3 | work-orders (form + view) | must-have | 1 | `pages/workorders/` | `/workorders/new`, `/workorders/:id`, `/workorders/edit/:id` | 4.3k\* | auth, work-orders (list), settings/dropdowns, accounts, locations, employees |
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
| 11 | uplifts | should-have | 3 | `pages/uplifts/` | `/uplifts` | 317 | auth, work-orders (full) |
| 12 | follow-ups | should-have | 3 | `pages/followup/` | `/followups`, `/followups/new`, `/followups/edit/:id` | 1.3k | auth, employees, accounts, locations |
| 13 | contacts | should-have | 3 | `pages/contacts/` | `/contacts`, `/contacts/new`, `/contacts/edit/:id` | 1.1k | auth |
| 14 | settings/task-templates | should-have | 3 | `pages/settings/` (TaskTemplates) | `/settings/task-templates` | 866\* | auth, work-orders (full) |
| 15 | calendar | later | 4 | `pages/calendar/` | `/calendar`, `/calendar/new`, `/calendar/edit/:id` | 1.9k | auth |
| 16 | vendor-portal | later | 4 | `pages/vendor-portal/` | `/v/:token`, `/v/:token/dashboard`, `/v/:token/pos`, `/v/:token/dispatch/:id` | 1.4k | auth, vendors |
| 17 | pm-schedules | later | 4 | `pages/PmSchedule/` | `/pmschedules`, `/pmschedules/new`, `/pmschedules/edit/:id` | 1.1k | auth |
| 18 | assets | later | 4 | `pages/assets/` | `/assets`, `/assets/new`, `/assets/edit/:id` | 638 | auth, accounts |
| 19 | reports / documents | later | 4 | — (no implementation) | `/reports`, `/documents` (menu only) | — | auth |
\* LOC for the entire folder; POC scope uses only the list submodule.
### Legacy complexity (reference)
| Feature | Complexity | Notes |
| ------------- | ---------- | --------------------------------------------------------- |
| auth | Low | Replace Redux/`tokenUtility` with AuthProvider + cookies |
| dashboard | Medium | Real KPIs via `GET /Dashboard/Stats` |
| work-orders | **High** | Monolithic view; dispatch, checklist, signoff |
| contacts | High | Legacy form **stub** (save TODO) — rewrite with RHF + Zod |
| employees | High | Large form |
| vendor-portal | High | URL token auth + signature |
| calendar | High | FullCalendar wrapper |
| pm-schedules | Medium | Legacy API bugs (`PmSchedule/Create` vs `Save`) |
---
## Dependency graph
```mermaid
flowchart TD
subgraph wave0 [Wave0_POC]
auth[auth]
woList[work_orders_list]
end
subgraph wave1 [Wave1_MustHave]
woFull[work_orders_full]
dash[dashboard]
dropdowns[settings_dropdowns]
end
subgraph wave2 [Wave2_Dependencies]
accounts[accounts]
locations[locations]
employees[employees]
end
subgraph wave3 [Wave3_ShouldHave]
vendors[vendors]
vendorPos[vendor_pos]
uplifts[uplifts]
followups[follow_ups]
contacts[contacts]
settingsTpl[task_templates]
end
subgraph wave4 [Wave4_Later]
calendar[calendar]
vendorPortal[vendor_portal]
pmSched[pm_schedules]
assets[assets]
reports[reports_documents]
end
auth --> woList
woList --> woFull
dropdowns --> woFull
accounts --> woFull
locations --> woFull
employees --> woFull
woFull --> vendors
vendors --> vendorPortal
woFull --> vendorPos
woFull --> uplifts
```
### Waves (summary)
| Wave | Tier | Features |
| ----- | ----------- | --------------------------------------------------------------------------- |
| **0** | POC | auth, work-orders (list) |
| **1** | must-have | work-orders (full), dashboard, settings/dropdowns |
| **2** | must-have | accounts, locations, employees |
| **3** | should-have | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
| **4** | later | calendar, vendor-portal, pm-schedules, assets, reports/documents |
> `accounts` and `locations` can progress in parallel with the WO view if the list POC uses data already available in the backend; the order above is the minimum sequence for the full WO form.
---
## Legacy gaps and cleanup
| Item | Location | Migration action |
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Duplicate dashboard | `pages/dashboard/` (mock prototype) | **Not routed** — ignore; use only `pages/Dashboard.js` as reference |
| Orphan login | `pages/user/api.js` | Delete in Phase 1 (Ky + auth); duplicates `pages/auth/` flow |
| Ghost menu | `Sidebar.js` → Reports `/reports`, Documents `/documents` | No routes or pages — implement from scratch (Wave 4) or remove from menu in `config/menu.ts` |
| Incomplete contacts form | `pages/contacts/form/` | Do not port stub; rewrite with RHF + Zod in Wave 3 |
| PM schedules API | `pages/PmSchedule/` | Document Create vs Save inconsistency; fix in rewrite |
---
## "Done" criteria per feature
A feature is **done** when all items below are met:
1. **`domain/<feature>/`** — Zod schemas, hooks/use-cases, types; no imports from `pages/`, Redux, axios or `apiUtil`
2. **`app/`** — thin views (list, form, view as scoped) registered in `app/routes.tsx`
3. **`api/api-paths.ts`** — feature endpoints centralized; responses via `handle-api-response.ts`
4. **`infra/query-key/query-key.ts`** — feature query keys
5. **Protected route** — navigation via `config/menu.ts` (when applicable to the wave)
6. **Delete legacy** — `pages/<feature>/` folder (or equivalent files) removed after validation
7. **No regression** — main flow tested manually or with Vitest (when available)
### Done criteria for special scope
| Scope | Additional criterion |
| ------------------------ | ---------------------------------------------------------------------------------- |
| **POC auth** | Login/logout with httpOnly cookies; `AuthProvider` replaces Redux + `tokenUtility` |
| **POC work-orders list** | Paginated/filtered list with Ky + RQ; no form/view in this delivery |
| **work-orders full** | Form + view (dispatch, checklist, signoff) functional |
| **settings/dropdowns** | Problem/Trade/SubTrade dropdowns available for WO form |
| **reports/documents** | Explicit decision: implement feature or remove menu links |
---
## References
- Architecture plan: [`ARCHITECTURE_PLAN.md`](ARCHITECTURE_PLAN.md)
- IrisLoan conventions: `.cursor/rules/seahaven-admin.mdc`
- Legacy routes: `src/App.js`
- Legacy menu: `src/components/Sidebar.js`

View file

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

544
docs/UI_DOCUMENTATION.md Normal file
View file

@ -0,0 +1,544 @@
# Sea Haven UI - Complete Architecture Documentation
> Modern React architecture with Redux Toolkit, React Query, and CSS Modules
---
## 📊 Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ 1. UI LAYER │
│ Pages / Components │
│ (What users see - buttons, forms, tables) │
└────────┬──────────┬──────────┬──────────┬──────────┬────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. CUSTOM HOOKS │
│ │
│ useWorkOrders useModal useDebounce usePMSchedules useAuth │
│ │
│ (Reusable logic - keeps components clean) │
└────────┬────────────────────────────────────┬────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ 3. STATE MANAGEMENT │ │ 3. STATE MANAGEMENT │
│ │ │ │
│ React Query │ │ Redux Toolkit │
│ Server State & Cache │ │ Auth & UI State │
│ │ │ │
│ • PM Schedules │ │ • User (logged in?) │
│ • Work Orders │ │ • Sidebar (open?) │
│ • Employees │ │ • Theme (dark/light?) │
│ • Automatic caching! │ │ • Reactive updates! │
└────────────┬───────────┘ └────────────┬───────────┘
│ │
└──────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. API LAYER │
│ │
│ API Services │
│ (All backend endpoints) │
│ │
│ • workOrdersApi • pmSchedulesApi • employeesApi │
│ │
│ Axios Client │
│ (with auth interceptors) │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. BACKEND │
│ │
│ REST API │
│ (Sea Haven Backend) │
└─────────────────────────────────────────────────────────────┘
```
---
## 📁 Folder Structure
```
src/
├── pages/ # What users see (thin - just display)
│ ├── PmSchedule/
│ │ └── list/List.js # PM Schedules page ✅ REFACTORED
│ ├── auth/
│ │ └── LoginPage.js # Login page ✅ USES REDUX
│ └── ...
│
├── components/ # Reusable UI pieces
│ ├── Topbar.js # Top navigation bar ✅ USES REDUX
│ ├── Sidebar.js # Side menu
│ ├── DeleteModal.js # Confirmation popup
│ └── SharedTable.js # Data table
│
├── hooks/ # Reusable logic (the magic!)
│ ├── useAuth.js # Login/logout with Redux
│ ├── useModal.js # Open/close popups
│ ├── useDebounce.js # Delay search typing
│ └── api/ # Data fetching hooks
│ ├── usePMSchedules.js
│ ├── useWorkOrders.js
│ └── useEmployees.js
│
├── app/ # Redux setup
│ ├── store.js # Main store
│ └── slices/
│ ├── authSlice.js # User login state
│ └── uiSlice.js # Sidebar, theme, etc.
│
├── lib/ # Core utilities
│ ├── queryClient.js # React Query config
│ └── api/
│ ├── client.js # Axios HTTP client
│ └── services.js # All API endpoints
│
└── constants/ # All constant values
├── index.js # Main constants
├── actionTypes.js # Redux actions (for reference)
└── queryKeys.js # React Query keys
```
---
## 🎯 Core Concepts
### 1. State Management: Redux vs React Query
**Use Redux for:**
- ✅ User authentication (login/logout)
- ✅ App-wide settings (theme, sidebar state)
- ✅ Data that needs to be shared everywhere
**Use React Query for:**
- ✅ API data (lists, details)
- ✅ Any server data
- ✅ Automatic caching and refetching
**Use useState for:**
- ✅ Local component state
- ✅ Form inputs
- ✅ UI toggles (dropdowns, tabs)
### 2. CSS Modules (Built-in - No Dependencies!)
**Old Way** (Global CSS):
```javascript
import './Component.css';
<div className="container">
```
**New Way** (CSS Modules):
```javascript
import styles from './Component.module.css';
<div className={styles.container}> // Automatically scoped!
```
**Benefits:**
- No naming conflicts
- Better tree-shaking
- No extra build tools needed
- Just rename `.css` → `.module.css`
### 3. Constants - No Magic Strings!
```javascript
// ❌ Bad
queryKey: ["pmschedules"];
setTimeout(fn, 600);
// ✅ Good
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from "../constants";
queryKey: [PM_SCHEDULES_LIST];
setTimeout(fn, DEBOUNCE_SEARCH);
```
---
## 💻 Code Examples
### Example 1: Loading Data with React Query
```javascript
import { usePMSchedules } from "../hooks/api/usePMSchedules";
import { useDebounce, DEBOUNCE_SEARCH } from "../constants";
function PMScheduleList() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
const { data, isLoading, error } = usePMSchedules({
search: debouncedSearch,
page: 1,
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<ul>
{data.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
```
### Example 2: Using Redux Auth
```javascript
import { useAuth } from "../hooks/useAuth";
function MyComponent() {
const { user, isAuthenticated, login, logout } = useAuth();
if (!isAuthenticated) {
return <button onClick={() => login(credentials)}>Login</button>;
}
return (
<div>
Welcome {user.name}!<button onClick={logout}>Logout</button>
</div>
);
}
```
### Example 3: Using Modals
```javascript
import { useModal } from "../hooks/useModal";
function MyComponent() {
const deleteModal = useModal();
return (
<div>
<button onClick={() => deleteModal.open(item)}>Delete</button>
{deleteModal.isOpen && (
<Modal onClose={deleteModal.close}>Delete {deleteModal.data.name}?</Modal>
)}
</div>
);
}
```
### Example 4: CSS Modules
```javascript
import styles from "./LoginPage.module.css";
function LoginPage() {
return (
<div className={styles.page}>
<div className={styles.wrapper}>
<div className={styles.panel}>
<div className={styles.header}>
<h5>User Login</h5>
</div>
<form className={styles.form}>
<input className={styles.formControl} />
<button className={styles.button}>Login</button>
</form>
</div>
</div>
</div>
);
}
```
---
## 🔧 Common Patterns
### Pattern 1: Fetch and Display Data
```javascript
const { data, isLoading, error } = useDataHook(params);
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Display data={data} />;
```
### Pattern 2: Delete with Confirmation
```javascript
const deleteMutation = useDeleteHook();
const deleteModal = useModal();
const handleDelete = async () => {
await deleteMutation.mutateAsync(deleteModal.data.id);
deleteModal.close();
// Automatically refetches list!
};
// In JSX:
<button onClick={() => deleteModal.open(item)}>Delete</button>
<Modal isOpen={deleteModal.isOpen} onConfirm={handleDelete} />
```
### Pattern 3: Search with Debounce
```javascript
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
const { data } = useDataHook({ search: debouncedSearch });
// User types → waits 600ms → then searches
```
---
## 📚 Constants Reference
### API Constants
```javascript
API_URL; // Backend URL
API_ERROR_MESSAGE; // Default error message
API_SUCCESS_MESSAGE; // Success message
```
### Timing Constants
```javascript
DEBOUNCE_SEARCH; // 600ms
CACHE_TIME_MEDIUM; // 5 minutes
STALE_TIME_MEDIUM; // 5 minutes
```
### View Modes
```javascript
VIEW_MODE_LIST; // 'list'
VIEW_MODE_CARD; // 'card'
```
### Status Types
```javascript
STATUS_OPEN; // 'Open'
STATUS_COMPLETED; // 'Completed'
```
### Query Keys
```javascript
PM_SCHEDULES_LIST; // 'pmSchedulesList'
WORK_ORDERS_LIST; // 'workOrdersList'
EMPLOYEES_DROPDOWN; // 'employeesDropdown'
```
### Storage Keys
```javascript
STORAGE_KEY_TOKEN; // 'token'
STORAGE_KEY_THEME; // 'theme'
```
---
## 🚀 Getting Started
### 1. Run Development Server
```bash
npm start
```
### 2. Build for Production
```bash
npm run build
```
### 3. Deploy to S3
```bash
aws s3 sync build/ s3://shoc-ui-app --acl public-read
```
---
## 🔄 Migration Guide
### Converting a Page to New Architecture
**Step 1: Use React Query Hook**
```javascript
// Old
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
// New
const { data, isLoading } = usePMSchedules({ page: 1 });
```
**Step 2: Use Constants**
```javascript
// Old
const [search, setSearch] = useState("");
useEffect(() => {
const timer = setTimeout(() => doSearch(search), 600);
return () => clearTimeout(timer);
}, [search]);
// New
import { DEBOUNCE_SEARCH } from "../constants";
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
```
**Step 3: Convert CSS to Modules**
```javascript
// 1. Rename: Component.css → Component.module.css
// 2. Update import: import styles from './Component.module.css';
// 3. Update JSX: className="foo" → className={styles.foo}
```
---
## ✅ What's Been Refactored
### Pages
- ✅ **LoginPage** - Uses Redux for auth
- ✅ **PM Schedules List** - Uses React Query, constants, CSS Modules
### Components
- ✅ **Topbar** - Uses Redux for user state
- ✅ **DeleteModal** - Reusable component
### Hooks
- ✅ **useAuth** - Redux integration for login/logout
- ✅ **useModal** - Reusable modal state
- ✅ **useDebounce** - Search debouncing
- ✅ **usePMSchedules** - PM Schedule API with React Query
- ✅ **useWorkOrders** - Work Orders API
- ✅ **useEmployees** - Employees API
---
## 🎯 Best Practices
1. **Always use constants** - Never hardcode strings
2. **Keep components thin** - Move logic to hooks
3. **Let React Query cache** - Don't manually manage loading
4. **Use CSS Modules** - Avoid global styles
5. **Follow patterns** - Look at PM Schedules as example
---
## 📖 Quick Reference
### File to Check for Examples
- `src/pages/PmSchedule/list/List.js` - Fully refactored list page
- `src/pages/auth/LoginPage.js` - Redux auth + CSS Modules
- `src/hooks/api/usePMSchedules.js` - React Query hook pattern
- `src/components/Topbar.js` - Redux integration
### When You Need To...
**Fetch data from API:**
→ Create/use a hook in `src/hooks/api/`
**Add a new constant:**
→ Add to `src/constants/index.js`
**Create a new page:**
→ Copy pattern from `PM Schedules List.js`
**Style a component:**
→ Create `Component.module.css` and import it
**Access user info:**
→ Use `const { user } = useAuth()`
**Show a confirmation modal:**
→ Use `const modal = useModal()`
---
## 🛠️ Troubleshooting
### Issue: Constants not found
**Solution:** Make sure you're importing from `'../constants'` (folder) not `'../constants.js'` (old file)
### Issue: CSS not scoped
**Solution:** File must be named `.module.css` and imported as `import styles from`
### Issue: Redux state not updating
**Solution:** Make sure you're using the hooks (`useAuth`, `useSelector`) not direct access
### Issue: React Query not caching
**Solution:** Check that query keys use constants and are consistent
---
## 🎉 Summary
### What We Built:
- ✅ Modern React architecture
- ✅ Redux Toolkit for global state
- ✅ React Query for server state
- ✅ CSS Modules for styling
- ✅ Centralized constants
- ✅ Custom hooks for reusable logic
### Benefits:
- 📉 70% less boilerplate code
- 🚀 Automatic caching and refetching
- 🎨 No CSS naming conflicts
- 🔧 Easier to maintain
- 📦 Smaller bundle size
- ⚡ Better performance
### Next Steps:
1. Refactor remaining pages using PM Schedules as template
2. Convert more CSS to CSS Modules
3. Add more API hooks as needed
4. Expand Redux slices for new features
---
**Welcome to the modern Sea Haven UI! 🎊**
_Last updated: 2026_

View file

@ -0,0 +1,148 @@
# Work Orders Board & Search API
Frontend integration contract for weekly board and advanced search filters.
## Base
| Item | Valor |
| ------------ | ------------------------------------------- |
| Prefixo | `/api/workorders` (alias: `/api/WorkOrder`) |
| Auth | `Authorization: Bearer {token}` (JWT) |
| Content-Type | Não necessário (GET) |
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
## Quando usar qual endpoint
| Modo FE | Endpoint | Quando |
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
| Barra principal (`advApplied === null`) | `GET /board` | Semana + dispatchers + tipo segment + search |
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
**Não misture parâmetros:** `weekStart`/`weekEnd` só no `/board`; `datePreset` só no `/search`.
## Query builders (FE)
- [`board-query-params.ts`](../../src/domain/work-orders/utils/board-query-params.ts) — `toBoardQueryParams`, `toBoardSearchQueryParams`
- Arrays na query: repetição simples (`types=2&types=6`), via `toUrlSearchParams` em `work-orders-api.ts`
## Barra → `GET /board`
| FE state | API param | Regra |
| --------------- | ---------------------- | ----------------------------------------------------------- |
| `search` | `search` | Enviar só se `length >= 2`; com 1 char, filtrar client-side |
| `weekMonday` | `weekStart` | ISO `YYYY-MM-DD` (segunda) |
| — | `weekEnd` | `weekStart + 4` (sexta) |
| `dispatcherIds` | `dispatchers` | `__unassigned` → `__unassigned__` |
| "My WOs" | `dispatchers={userId}` | Equivalente a `myWorkOrders=true` |
| `typeFilter` | `types` / `overdue` | Tipos reais em `types`; past-due via `overdue=true` (OR) |
## Advanced → `GET /board/search`
| FE state | API param | Regra |
| ---------------- | --------------------- | ------------------------------------------- |
| `page` | `page` | **0-based** (primeira página = `0`) |
| `pageSize` | `pageSize` | Default `100`, max `100` |
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
| `sortDir` | `sortDir` | `asc` \| `desc` |
| `search` | `search` | `>= 2` chars |
| `dateRange` | `datePreset` | PascalCase (ver abaixo) |
| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` |
| `sites` (IDs UI) | `sites` | **Site codes**, não location ID |
| `types` | `types` | Integers de `WorkOrderType` reais |
| overdue (UI) | `overdue` | `true` = past-due (`isPastDue`); ver abaixo |
| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` |
| `statuses` | `statuses` | Integers 1–10 |
| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) |
| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) |
| `docs` | `docStatuses` | No=2, Yes=1, NN=3 |
### `datePreset`
| FE key | API value |
| --------------- | ------------- |
| `this-week` | `ThisWeek` |
| `last-week` | `LastWeek` |
| `this-month` | `ThisMonth` |
| `last-3-months` | `Last3Months` |
| `next-week` | `NextWeek` |
| `next-month` | `NextMonth` |
| `custom` | `Custom` |
### `types` — WorkOrderType
Somente tipos reais de work order. **Não** use sentinel `99` para overdue.
| Label FE | API |
| --------- | -------------------------------------------- |
| PM | `2` |
| Emergency | `3` |
| Reactive | `6` |
| Add-On | `7` |
| Other | tipo real do enum (não é filtro de past-due) |
### `overdue` — past-due
| FE / UI | API param | Regra |
| ------- | -------------- | ----------------------------------------- |
| Overdue | `overdue=true` | Filtra work orders past-due (`isPastDue`) |
`types` e `overdue` combinam com **OR**: uma WO entra no resultado se corresponder a algum `types` **ou** estiver overdue (quando `overdue=true`).
Exemplos:
- Só PM e Emergency: `types=2&types=3`
- Só past-due: `overdue=true`
- PM **ou** past-due: `types=2&overdue=true`
### `statuses` — LifecycleStatus
Incomplete=1, Pending=2, Scheduled=3, En Route=4, On Site=5, In Progress=6, Completed=7, Rescheduled=8, Canceled=9, Pending Quote=10.
### `docStatuses`
Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3.
## Respostas
### `/board`
```json
{
"weekStart": "2026-07-14",
"weekEnd": "2026-07-18",
"counts": { "total": 42, "returned": 10 },
"scheduled": [],
"unscheduled": []
}
```
### `/board/search`
```json
{
"items": [],
"totalCount": 150,
"page": 0,
"pageSize": 100,
"totalPages": 2,
"hasPrevious": false,
"hasNext": true
}
```
## Erros HTTP
| Situação | Status |
| --------------------------------------------------- | ------ |
| `weekEnd < weekStart` ou janela > 7 dias | 400 |
| `datePreset=Custom` sem datas / `dateTo < dateFrom` | 400 |
| `sortBy` inválido | 400 |
| Sem token / expirado | 401 |
Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`.
## Validação UI
O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário.

View file

@ -0,0 +1,58 @@
# Pending BE contract (WO design parity gaps)
Local checklist for FE branches off `dev`. Confirm with backend before shipping PR3/PR5 to production. Until confirmed, FE may use typed clients + mocks.
## Board row / PATCH
| Field | Needed by | Notes |
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date |
| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` |
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` |
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue)
| Method | Path | Body |
| ------ | ------------------------------------------- | --------------------------------- |
| GET | `workorders/{id}/uplifts` | — |
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` |
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — |
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` |
Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`.
## Search query params (`GET workorders/board/search`)
| Param | Meaning |
| ------------------------------ | ------------------------- |
| `severities` | multi `1`–`5` |
| `rescheduled` / `carriedOver` | boolean (≥2 counters) |
| `addOn` | boolean (`isAddOn`) |
| `flagColors` | multi `#RRGGBB` |
| `internalOnly` | WO# starts with `SH` |
| `avetaOnly` | if `avetaRequired` exists |
| `hasUplift` + `upliftStatuses` | uplift filters |
Already documented in `board-search-api.md`: datePreset, sites, types, overdue, dispatchers, statuses, pmTypes, vendorIds, docStatuses.
## Comments
| Field | Needed by |
| ---------------------------------------- | --------- |
| POST `{ text, mentions?: string[] }` | PR9 |
| Response includes mentions for highlight | PR9 |
## Vendor
| Item | Needed by |
| -------------------------------------------------------------- | --------- |
| Create technician under company (confirm existing vendors API) | PR7 |
## Optional
| Item | Needed by |
| ----------------------------------- | -------------------------------- |
| 422 on duplicate WO number (global) | PR8 (FE dialog works without it) |

104
e2e/auth/login.spec.ts Normal file
View file

@ -0,0 +1,104 @@
import { expect, test, type Page } from "@playwright/test";
const TEST_USER = {
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "user@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Test User",
id: "1",
};
async function mockDashboardStats(page: Page) {
await page.route("**/api/Dashboard/Stats", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
openWorkOrders: 0,
pendingApprovals: 0,
scheduledPMs: 0,
activeAssets: 0,
},
}),
});
});
}
test.describe("Login page", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.removeItem("auth");
});
await mockDashboardStats(page);
});
test("loads login page with form fields", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await expect(page.locator("#login-email")).toBeVisible();
await expect(page.locator("#login-password")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
});
test("disables submit until email and password are valid", async ({ page }) => {
await page.goto("/login");
const submitButton = page.getByRole("button", { name: "Sign In" });
await expect(submitButton).toBeDisabled();
await page.locator("#login-email").fill("not-an-email");
await page.locator("#login-password").fill("secret123");
await expect(submitButton).toBeDisabled();
await page.locator("#login-email").fill("user@seahavenind.com");
await expect(submitButton).toBeEnabled();
});
test("shows error message for invalid credentials", async ({ page }) => {
await page.route("**/api/Authentication/Login", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "Invalid credentials" }),
});
});
await page.goto("/login");
await page.locator("#login-email").fill("user@seahavenind.com");
await page.locator("#login-password").fill("wrong-password");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Invalid email or password. Please try again.")).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
});
test("logs in successfully and navigates to dashboard", async ({ page }) => {
await page.route("**/api/Authentication/Login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: TEST_USER }),
});
});
await page.goto("/login");
await page.locator("#login-email").fill("user@seahavenind.com");
await page.locator("#login-password").fill("secret123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible();
const storedAuth = await page.evaluate(() => localStorage.getItem("auth"));
expect(storedAuth).toContain(TEST_USER.token);
expect(storedAuth).toContain(TEST_USER.fullname);
});
});

567
e2e/vendors/vendor-operations.spec.ts vendored Normal file
View file

@ -0,0 +1,567 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-operations-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor Operations Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function authenticate(page: Page) {
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/Dashboard/Stats", (route) =>
fulfillJson(route, {
data: {
openWorkOrders: 0,
pendingApprovals: 0,
scheduledPMs: 0,
activeAssets: 0,
},
}),
);
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
}
test.describe("Vendor operations", () => {
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
await authenticate(page);
await page.route("**/api/vendor-operations/insights*", (route) =>
fulfillJson(route, { data: [] }),
);
await page.goto("/vendors/insights");
const vendorLinks = page.locator('a[href^="/vendors"]');
await expect(
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
).toHaveAttribute("aria-current", "page");
await expect(
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
).not.toHaveAttribute("aria-current", "page");
await expect(
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
).not.toHaveAttribute("aria-current", "page");
});
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await authenticate(page);
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
await page.route("**/api/Vendor/GetVendorList*", (route) =>
fulfillJson(route, { Data: [], TotalCount: 0 }),
);
await page.goto("/vendor-pos");
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
await expect
.poll(() =>
page
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
.evaluate((element) => element.clientWidth),
)
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
fulfillJson(route, {
items: [
{
id: "no-vendor-501",
type: "NoVendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
],
},
],
}),
);
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
});
test("filters insights and exposes both export formats", async ({ page }) => {
await authenticate(page);
const requestedUrls: string[] = [];
await page.route("**/api/vendor-operations/insights*", async (route) => {
requestedUrls.push(route.request().url());
const { pathname } = new URL(route.request().url());
if (pathname.endsWith(".csv")) {
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
return;
}
if (pathname.endsWith(".pdf")) {
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
return;
}
await fulfillJson(route, {
data: [
{
vendorId: 7,
vendor: "Gateway Plumbing",
completedJobs: 12,
cancellationRefusalRate: 0.1,
onTimeArrivalRate: 0.9,
onTimeCompletionRate: 0.8,
averageCycleHours: 5.5,
},
],
});
});
await page.goto("/vendors/insights");
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
await page.getByLabel("From", { exact: true }).fill("2026-01-01");
await page.getByLabel("To", { exact: true }).fill("2026-01-31");
await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
const insightsQueryHas = (name: string, value: string) =>
requestedUrls.some(
(url) =>
new URL(url).pathname.endsWith("/insights") &&
new URL(url).searchParams.get(name) === value,
);
await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
await page.getByRole("button", { name: "Export CSV" }).click();
await page.getByRole("button", { name: "Export PDF" }).click();
const exportRequest = (format: "csv" | "pdf") =>
requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
const exportHasFilters = (url: string | undefined) => {
if (!url) return false;
const params = new URL(url).searchParams;
return (
params.get("from") === "2026-01-01" &&
params.get("to") === "2026-01-31" &&
params.get("trade") === "Plumbing"
);
};
await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
});
test("reorders and saves preferred vendors for a site", async ({ page }) => {
await authenticate(page);
let savedPreferences: unknown;
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Vendor/Dropdown*", (route) =>
fulfillJson(route, {
data: [
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
],
}),
);
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
if (route.request().method() === "PUT") {
savedPreferences = route.request().postDataJSON();
await fulfillJson(route, savedPreferences);
return;
}
await fulfillJson(route, [
{
id: 1,
locationId: 10,
vendorId: 7,
vendorName: "Gateway Plumbing",
trade: "Plumbing",
sortOrder: 0,
isStale: false,
},
{
id: 2,
locationId: 10,
vendorId: 8,
vendorName: "Metro HVAC",
trade: "HVAC",
sortOrder: 1,
isStale: false,
},
]);
});
await page.goto("/vendors/preferences");
await page.getByLabel("Site").click();
await page.getByRole("option", { name: "St. Louis HQ" }).click();
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
await page.getByRole("button", { name: "Save preferences" }).click();
await expect
.poll(() => savedPreferences)
.toEqual([
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
]);
});
test("creates a work order and assigns the selected technician with ETA", async ({ page }) => {
await authenticate(page);
let assignment: unknown;
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
fulfillJson(route, { data: [] }),
);
await page.route("**/api/vendor-operations/availability*", (route) =>
fulfillJson(route, {
data: [
{
id: 7,
companyName: "Gateway Plumbing",
contactName: "Taylor Tech",
availabilityStatus: "Available",
isPreferred: true,
conflicts: [],
},
],
}),
);
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
fulfillJson(route, {
data: {
Id: 900,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
Comments: [],
AuditLog: [],
Dispatches: [],
},
}),
);
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
assignment = route.request().postDataJSON();
await fulfillJson(route, {
availabilityStatus: "Available",
conflicts: [
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
],
});
});
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
data: {
Id: 900,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
Comments: [],
AuditLog: [],
Dispatches: [],
},
}),
);
await page.goto("/workorders/new");
await page.getByLabel("Title").fill("Boiler repair");
await page.getByLabel("Location").click();
await page.getByRole("option", { name: "St. Louis HQ" }).click();
await page.getByLabel("Priority").click();
await page.getByRole("option", { name: "Sev 1" }).click();
await page.getByLabel("Scheduled").fill("2026-07-25");
await page.getByLabel("Appointment start").fill("2026-07-25T10:00");
await page.getByLabel("Appointment end").fill("2026-07-25T12:00");
await page.getByLabel("Company + Technician").click();
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30");
await page.getByLabel("Manual ETA override").check();
await page.getByRole("button", { name: "Create" }).click();
await expect
.poll(() => assignment)
.toEqual({
vendorId: 7,
scheduledStart: "2026-07-25T10:00:00.000Z",
scheduledEnd: "2026-07-25T12:00:00.000Z",
estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
etaManualOverride: true,
});
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
await expect(page).toHaveURL(/\/workorders\/900$/);
});
test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => {
const context = await browser.newContext({ timezoneId: "America/New_York" });
const page = await context.newPage();
await authenticate(page);
let assignment: Record<string, unknown> | undefined;
const workOrder = {
Id: 901,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
CreatedDate: "2026-07-20",
ScheduledDate: "2026-07-25",
Comments: [],
AuditLog: [],
Dispatches: [
{
Id: 44,
VendorId: 7,
Status: "Acknowledged",
EstimatedArrivalAt: "2026-07-25T12:30:00.000Z",
EtaManualOverride: true,
},
],
};
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
fulfillJson(route, { data: [] }),
);
await page.route("**/api/vendor-operations/availability*", (route) =>
fulfillJson(route, {
data: [
{
id: 7,
companyName: "Gateway Plumbing",
contactName: "Taylor Tech",
availabilityStatus: "Available",
isPreferred: true,
conflicts: [],
},
],
}),
);
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, { data: workOrder }),
);
await page.route("**/api/WorkOrder/EditWorkorder", (route) =>
fulfillJson(route, { data: workOrder }),
);
await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => {
assignment = route.request().postDataJSON() as Record<string, unknown>;
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
});
await page.goto("/workorders/edit/901");
await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30");
await page.getByRole("button", { name: "Update" }).click();
await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z");
await context.close();
});
test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({
browser,
}) => {
// America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT.
// The window below straddles the transition: 01:00 is EST (UTC-5) while
// 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no
// fixed-window assumption remains.
const context = await browser.newContext({ timezoneId: "America/New_York" });
const page = await context.newPage();
await authenticate(page);
let assignment: Record<string, unknown> | undefined;
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
fulfillJson(route, { data: [] }),
);
await page.route("**/api/vendor-operations/availability*", (route) =>
fulfillJson(route, {
data: [
{
id: 7,
companyName: "Gateway Plumbing",
contactName: "Taylor Tech",
availabilityStatus: "Available",
isPreferred: true,
conflicts: [],
},
],
}),
);
await page.route("**/api/WorkOrder/AddWorkorder", (route) =>
fulfillJson(route, {
data: {
Id: 902,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
Comments: [],
AuditLog: [],
Dispatches: [],
},
}),
);
await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => {
assignment = route.request().postDataJSON() as Record<string, unknown>;
await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] });
});
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
data: {
Id: 902,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
Comments: [],
AuditLog: [],
Dispatches: [],
},
}),
);
await page.goto("/workorders/new");
await page.getByLabel("Title").fill("Boiler repair");
await page.getByLabel("Location").click();
await page.getByRole("option", { name: "St. Louis HQ" }).click();
await page.getByLabel("Priority").click();
await page.getByRole("option", { name: "Sev 1" }).click();
await page.getByLabel("Appointment start").fill("2026-03-08T01:00");
await page.getByLabel("Appointment end").fill("2026-03-08T04:00");
await page.getByLabel("Company + Technician").click();
await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click();
await page.getByRole("button", { name: "Create" }).click();
await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z");
await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z");
await context.close();
});
test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
fulfillJson(route, { data: [] }),
);
await page.route("**/api/vendor-operations/availability*", (route) =>
fulfillJson(route, {
data: [
{
id: 7,
companyName: "Gateway Plumbing",
contactName: "Taylor Tech",
availabilityStatus: "Available",
isPreferred: true,
conflicts: [],
},
],
}),
);
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
data: {
Id: 501,
WorkerOrderTitle: "Boiler repair",
LocationId: 10,
Priority: "Sev 1",
Status: "Open",
Comments: [],
AuditLog: [],
Dispatches: [],
},
}),
);
await page.goto("/workorders/edit/501?assignVendor=1");
const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
await expect(vendorSection).toBeVisible();
await expect(vendorSection).toBeFocused();
await page.getByLabel("Company + Technician").click();
const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ });
await expect(option).toContainText("Unknown");
});
});

552
e2e/vendors/vendors.spec.ts vendored Normal file
View file

@ -0,0 +1,552 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor E2E Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
interface MockState {
listUrls: string[];
createdBody?: Record<string, unknown>;
updatedBody?: Record<string, unknown>;
deletedId?: string;
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function captureVisual(page: Page, path: string) {
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
await page.waitForTimeout(400);
await page.screenshot({ path, fullPage: true });
}
async function mockVendorApi(
page: Page,
options: {
listError?: boolean;
deactivationBlocked?: boolean;
deleteConflict?: boolean;
legacyGateway?: boolean;
} = {},
) {
const state: MockState = { listUrls: [] };
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
if (options.legacyGateway) {
vendorRecords[0].Phone = "555-1234 ext 9";
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
}
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
}),
);
await page.route("**/api/Vendor/facets*", (route) =>
fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
companyPhone: vendor.CompanyPhone,
email: vendor.Email,
address: vendor.Address,
city: vendor.City,
state: vendor.State,
zip: vendor.Zip,
googleMapsUrl: vendor.GoogleMapsUrl,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
}),
);
await page.route("**/api/Vendor/GetVendorList*", async (route) => {
const url = new URL(route.request().url());
state.listUrls.push(url.toString());
if (options.listError && url.searchParams.get("pageSize") !== "1") {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const isActive = url.searchParams.get("isActive") !== "false";
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]");
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company),
);
const isCount = url.searchParams.get("pageSize") === "1";
await fulfillJson(route, {
Data: isCount ? filtered.slice(0, 1) : filtered,
TotalCount: filtered.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
});
await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1];
if (request.method() === "POST") {
state.createdBody = request.postDataJSON();
await fulfillJson(route, {
...state.createdBody,
companyId: 999,
rowVersion: "rv-created",
});
return;
}
if (request.method() === "PUT" && pathCompanyId) {
state.updatedBody = request.postDataJSON();
const technicians = Array.isArray(state.updatedBody.technicians)
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
: [];
for (const technician of technicians) {
const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id));
if (vendor && typeof technician.isActive === "boolean") {
vendor.IsActive = technician.isActive;
}
}
await fulfillJson(route, {
...state.updatedBody,
companyId: Number(pathCompanyId),
rowVersion: "rv-updated",
});
return;
}
const vendorId = url.searchParams.get("vendorId");
const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId;
const anchor = vendorId
? vendorRecords.find((vendor) => String(vendor.Id) === vendorId)
: vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId);
if (!anchor) {
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
return;
}
await fulfillJson(route, {
companyId: anchor.CompanyId,
rowVersion: "rv-1",
name: anchor.CompanyName,
companyPhone: anchor.CompanyPhone,
email: anchor.Email,
address: anchor.Address,
city: anchor.City,
state: anchor.State,
zip: anchor.Zip,
googleMapsUrl: anchor.GoogleMapsUrl,
notes: anchor.Notes,
technicians: vendorRecords
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
.map((vendor) => ({
id: vendor.Id,
contactName: vendor.ContactName,
phone: vendor.Phone,
email: vendor.Email,
preferredContact: vendor.PreferredContact ?? "Phone",
tradeSpecialties: vendor.TradeSpecialties,
isActive: vendor.IsActive,
totalJobs: vendor.TotalJobs,
})),
});
});
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
const vendorId = Number(route.request().url().split("/").at(-2));
await fulfillJson(route, {
data: {
vendorId,
canDeactivate: !options.deactivationBlocked,
openWorkOrders: options.deactivationBlocked
? [
{
workOrderId: 501,
workOrderNumber: "WO-501",
workOrderTitle: "Emergency boiler repair",
status: "Dispatched",
scheduledDate: "2026-07-24T14:00:00Z",
dispatchId: 801,
},
]
: [],
},
});
});
await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) =>
fulfillJson(route, {
data: {
portalUrl: "https://vendor.example.test/token",
issuedAt: "2026-07-31T12:00:00Z",
expiresAt: "2026-08-07T12:00:00Z",
},
}),
);
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON();
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
vendor.IsActive = state.updatedBody.isActive;
}
await fulfillJson(route, {
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
});
return;
}
if (route.request().method() === "DELETE") {
if (options.deleteConflict) {
await fulfillJson(
route,
{ message: "Vendor has open work orders and cannot be deactivated." },
409,
);
return;
}
state.deletedId = id;
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (options.legacyGateway && vendor) vendor.IsActive = false;
await fulfillJson(route, { status: "200" });
return;
}
const vendor = vendorRecords.find((item) => String(item.Id) === id);
await fulfillJson(route, { data: vendor });
});
await page.route(/\/api\/vendors\/?$/, async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
state.createdBody = route.request().postDataJSON();
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
});
return state;
}
test.describe("Vendor directory prototype parity", () => {
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const heading of [
"Company",
"Technician",
"Location",
"Trade",
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
true,
);
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await page.getByPlaceholder("Search name, company, trade...").clear();
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
await page.getByRole("button", { name: /Company/ }).click();
await page.getByPlaceholder("All companies").click();
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).toBe(true);
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
"history will be preserved",
);
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("adds a technician to an existing company, then views and edits the aggregate", async ({
page,
}) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(
page.getByText("Register a company and add zero, one, or multiple technicians."),
).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician Name").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
notes: "Created in browser E2E",
rowVersion: "rv-1",
});
expect(state.updatedBody?.technicians).toEqual(
expect.arrayContaining([
expect.objectContaining({
contactName: "New Technician",
tradeSpecialties: "Plumbing, HVAC",
}),
]),
);
const submittedTechnicians = Array.isArray(state.updatedBody?.technicians)
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
: [];
const newTechnician = submittedTechnicians.find(
(technician) => technician.contactName === "New Technician",
);
expect(newTechnician?.preferredContact).toBeUndefined();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByLabel("Notes").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (2)" }).click();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByRole("switch", { name: "Inactive" }).first().click();
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
await addButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
await expect(addButton).toBeFocused();
await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
});
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {
await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer).toBeVisible();
await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible();
await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
});
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
page,
}) => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
expect(blockedState.deletedId).toBeUndefined();
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
await page.unrouteAll({ behavior: "wait" });
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
name: /^Deactivate$/,
})
.click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
/open work orders|conflict/i,
);
expect(racedState.deletedId).toBeUndefined();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockVendorApi(page, { listError: true });
await page.goto("/vendors");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();
const box = await filterDrawer.boundingBox();
expect(box?.width).toBeLessThanOrEqual(391);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click();
});
});

View file

@ -0,0 +1,91 @@
import { expect, test, type Route } from "@playwright/test";
const TEST_USER = {
token: "work-order-vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
test("edits only the vendor assigned to the current work order", async ({ page }) => {
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
Id: 501,
InternalWONumber: "WO-501",
WorkerOrderNumber: "CUSTOMER-501",
WorkerOrderTitle: "Emergency boiler repair",
Status: "Dispatched",
Comments: [],
AuditLog: [],
Dispatches: [
{
Id: 801,
DispatchNumber: "D-801",
VendorId: 7,
VendorName: "Gateway Plumbing",
VendorEmail: "dispatch@gateway.test",
Status: "Sent",
DispatchedAt: "2026-07-23T12:00:00Z",
},
],
}),
);
await page.route("**/api/vendors/7/work-order-update", async (route) => {
updateRequest = {
url: route.request().url(),
body: route.request().postDataJSON() as Record<string, unknown>,
};
await fulfillJson(route, { data: { id: 7 } });
});
await page.route("**/api/vendors/7", (route) =>
fulfillJson(route, {
data: {
Id: 7,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
PreferredContact: "Email",
Phone: "3145550198",
Email: "dispatch@gateway.test",
Notes: "Preferred after-hours vendor",
IsActive: true,
},
}),
);
await page.route("**/api/workorders/lookups/dispatchers", (route) =>
fulfillJson(route, { data: [] }),
);
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
await page.goto("/workorders/501");
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
await page.getByRole("button", { name: "Edit vendor" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
await expect(page.getByText("Preferred contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
await page.getByLabel("Technician name").fill("Taylor Tech");
await page.getByLabel("Phone").fill("3145550111");
await page.getByLabel("Email").fill("taylor@example.com");
await page.getByLabel("Notes").fill("Text before arrival");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
expect(updateRequest?.body).toEqual({
workOrderId: 501,
contactName: "Taylor Tech",
phone: "(314) 555-0111",
email: "taylor@example.com",
notes: "Text before arrival",
});
});

View file

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

View file

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

103
eslint.config.js Normal file

File diff suppressed because one or more lines are too long

28
index.html Normal file
View file

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1c75bc" />
<meta name="description" content="Sea Haven - Work Orders" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<title>Sea Haven - Work Orders</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

161
infra/cdk/README.md Normal file
View file

@ -0,0 +1,161 @@
# Infrastructure & CI/CD — Sea Haven SHOC frontend
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
deployed through the org's **reusable** GitHub Actions workflow.
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** the SPA calls the backend **directly** over HTTPS at
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
allows CORS). CloudFront serves static content only — no `/api` proxy.
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
per-environment (see the note under "Adding staging / prod").
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
- **Environments:** `dev` only today, deployed on push to the `dev` branch.
```
infra/cdk/
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yml quality gates (lint / build / test / e2e)
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
```
## What the stack creates
| Resource | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
singleton account resource — the stack only _imports_ it (created in step 2),
so `cdk destroy` can't delete a resource shared by other roles.
---
## One-time setup (run by a human with admin AWS creds)
### 1. Authenticate to the AWS account
```bash
aws configure # or: aws sso login --profile <admin>
aws sts get-caller-identity # confirm the right account + region (us-east-1)
```
### 2. Ensure the GitHub OIDC provider exists (once per account)
```bash
aws iam list-open-id-connect-providers
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
# no longer required — AWS validates GitHub against its own trust store):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
```
### 3. CDK bootstrap (once per account/region)
```bash
cd infra/cdk
npm ci
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
```
### 4. Domain, cert, and API URL (already wired for dev)
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
| Context key | Value |
| --------------------------------- | ------------------------------------------------------------ |
| `domainNames` | `dev.seahaven.com` |
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
baked into the build. Per-environment; override for staging/prod.
### 5. First deploy (locally, with admin creds)
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
locally. This provisions infra + the role:
```bash
cd infra/cdk
npx cdk deploy
```
Note the `DeployRoleArn` output. Then push the first content (or just push to
`dev` and let CI do everything from here on):
```bash
# from repo root, optional manual first content publish:
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
### 6. Set the one GitHub secret
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
```bash
REPO=Sea-Haven-Industries/shoc-frontend-new
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
```
(Or **Settings → Secrets and variables → Actions → Secrets**.)
### 7. From now on: push to `dev`
```bash
git push origin dev
```
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
the `SiteUrl` output.
> First-run verification: this first push is what actually exercises the role's
> permissions and the OIDC trust through the reusable workflow (the local
> bootstrap used admin creds and tested none of that). Watch for
> credential/OIDC errors and a green post-deploy step.
---
## Adding staging / prod later
Separate accounts: deploy this stack there with per-env `domainNames`,
`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's
`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`.
Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is
baked into `vite build`** — so each environment needs its own build with its own
API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment
in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than
relying on the committed `.env.production` (which carries the dev value). The
backend must also allow CORS from each frontend origin.
## Notes
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still
deploys (matches the org's push-time-CD model). Gating deploy on CI is a
follow-up, not part of enabling CICD.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

40
infra/cdk/bin/app.ts Normal file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env node
import { App, Tags } from "aws-cdk-lib";
import { FrontendStack } from "../lib/frontend-stack";
const app = new App();
// Defaults match the dev setup; override via `-c key=value` on the CLI.
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
.split(",")
.map((d: string) => d.trim())
.filter((d: string) => d.length > 0);
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
// Route 53 hosted zone (this account) for the custom-domain alias record.
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
},
});
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", envName);
Tags.of(stack).add("ManagedBy", "cdk");

19
infra/cdk/cdk.json Normal file
View file

@ -0,0 +1,19 @@
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": {
"include": ["**"],
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"]
},
"context": {
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
"domainNames": "dev.seahaven.com",
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
"hostedZoneId": "Z07671212N75U4YLPWZR8",
"hostedZoneName": "dev.seahaven.com"
}
}

View file

@ -0,0 +1,238 @@
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
export interface FrontendStackProps extends StackProps {
/** Environment label, e.g. "dev". Used in names/tags. */
readonly envName: string;
/** GitHub repo in owner/name form, for OIDC trust scoping. */
readonly githubRepo: string;
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain.
*/
readonly domainNames: string[];
/**
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
* use a certificate from another account, so for Option B the cert must live
* in whichever account this stack deploys to.
*/
readonly certificateArn: string;
/**
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
* record in. Empty = don't manage DNS (add the record manually). When set,
* hostedZoneName must also be provided.
*/
readonly hostedZoneId: string;
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
readonly hostedZoneName: string;
}
/**
* Static SPA hosting for the Sea Haven SHOC frontend:
* - private S3 bucket (no public access; CloudFront reads it via OAC)
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
* - a GitHub Actions OIDC deploy role
*
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
* stack only owns the infrastructure, and the deploy role carries the
* permissions those post-deploy steps need.
*/
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props);
const {
envName,
githubRepo,
deployBranch,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
} = props;
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
);
}
// --- Origin bucket: private, encrypted, no public access ----------------
const bucket = new s3.Bucket(this, "SiteBucket", {
bucketName: `seahaven-shoc-frontend-${envName}`,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
// dev artifacts are reproducible from the build — safe to tear down.
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
// to /index.html so deep links resolve. Done with a CloudFront Function
// rather than customErrorResponses so real asset 404s stay 404s.
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
comment: "SPA routing: rewrite extensionless paths to /index.html",
code: cloudfront.FunctionCode.fromInline(
[
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
].join("\n"),
),
});
// --- CloudFront: serves the static SPA from S3 -------------------------
// The SPA calls the backend directly at its absolute HTTPS URL
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
const distribution = new cloudfront.Distribution(this, "Distribution", {
comment: `SeaHaven SHOC frontend (${envName})`,
defaultRootObject: "index.html",
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
// CloudFront uses its default *.cloudfront.net domain + certificate.
domainNames: hasCustomDomain ? domainNames : undefined,
certificate: hasCustomDomain
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
: undefined,
minimumProtocolVersion: hasCustomDomain
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
: undefined,
defaultBehavior: {
// withOriginAccessControl wires up OAC + the bucket policy automatically.
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
compress: true,
functionAssociations: [
{
function: spaRewrite,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
});
// --- GitHub Actions OIDC deploy role -----------------------------------
// The OIDC provider is a singleton account-global resource, created once
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
// lifecycle (including `cdk destroy`) never deletes a resource shared by
// every role in the account.
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
this,
"GitHubOidcProvider",
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
);
const deployRole = new iam.Role(this, "GithubDeployRole", {
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
}),
});
// The whole `cd-cdk.yaml` job runs as this role. Permissions it needs:
// 1. assume the CDK bootstrap roles -> `cdk deploy`
// 2. describe the stack -> cd-cdk pre-flight / health-check / output reads
// 3. read/write the bucket -> post-deploy `aws s3 sync`
// 4. invalidate the distribution -> post-deploy cache bust
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "DescribeStack",
actions: ["cloudformation:DescribeStacks"],
resources: [
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
],
}),
);
bucket.grantReadWrite(deployRole);
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "InvalidateDistribution",
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
resources: [
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
],
}),
);
// --- DNS: point the custom domain at CloudFront ------------------------
// Only when a hosted zone is supplied (it must be in THIS account). Creates
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
if (hostedZoneId && hasCustomDomain) {
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId,
zoneName: hostedZoneName,
});
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
// apex record when the domain equals the zone name.
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
new route53.ARecord(this, "AliasA", { zone, recordName, target });
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
}
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
});
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
});
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
});
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
});
}
}

514
infra/cdk/package-lock.json generated Normal file
View file

@ -0,0 +1,514 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shoc-frontend-infra",
"version": "0.1.0",
"dependencies": {
"aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2"
},
"bin": {
"app": "bin/app.ts"
},
"devDependencies": {
"@types/node": "^24.13.3",
"aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2",
"typescript": "~6.0.3"
},
"engines": {
"node": ">=22.22.1"
}
},
"node_modules/@aws-cdk/asset-awscli-v1": {
"version": "2.2.282",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "54.9.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz",
"integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.5"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
"version": "7.8.5",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/aws-cdk": {
"version": "2.1130.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
"integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/aws-cdk-lib": {
"version": "2.261.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
"integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
"bundleDependencies": [
"@balena/dockerignore",
"@aws-cdk/cloud-assembly-api",
"case",
"fs-extra",
"ignore",
"jsonschema",
"minimatch",
"punycode",
"semver",
"yaml",
"mime-types"
],
"license": "Apache-2.0",
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.282",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
"@aws-cdk/cloud-assembly-api": "^2.2.5",
"@aws-cdk/cloud-assembly-schema": "^54.0.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.5",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
"minimatch": "^10.2.5",
"punycode": "^2.3.1",
"semver": "^7.8.1",
"yaml": "1.10.3"
},
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"constructs": "^10.5.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
"version": "2.2.5",
"inBundle": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@aws-cdk/cloud-assembly-schema": ">=53.28.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
"version": "1.0.2",
"inBundle": true,
"license": "Apache-2.0"
},
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
"version": "4.0.4",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
"version": "5.0.6",
"inBundle": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/case": {
"version": "1.6.3",
"inBundle": true,
"license": "(MIT OR GPL-3.0-or-later)",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
"version": "11.3.5",
"inBundle": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
"version": "4.2.11",
"inBundle": true,
"license": "ISC"
},
"node_modules/aws-cdk-lib/node_modules/ignore": {
"version": "5.3.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
"version": "6.2.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-db": {
"version": "1.52.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-types": {
"version": "2.1.35",
"inBundle": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/minimatch": {
"version": "10.2.5",
"inBundle": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/aws-cdk-lib/node_modules/punycode": {
"version": "2.3.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/aws-cdk-lib/node_modules/semver": {
"version": "7.8.1",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/universalify": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/yaml": {
"version": "1.10.3",
"inBundle": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/constructs": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
"license": "Apache-2.0"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

29
infra/cdk/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"private": true,
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.",
"bin": {
"app": "bin/app.ts"
},
"engines": {
"node": ">=22.22.1"
},
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"diff": "cdk diff",
"deploy": "cdk deploy"
},
"devDependencies": {
"@types/node": "^24.13.3",
"aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2",
"typescript": "~6.0.3"
},
"dependencies": {
"aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2"
},
"packageManager": "npm@11.16.0"
}

25
infra/cdk/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["bin/**/*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "cdk.out"]
}

6880
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

105
package.json Normal file
View file

@ -0,0 +1,105 @@
{
"name": "seahaven-new-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"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": {
"src/{api,domain,app,lib,providers,infra,config,hooks,components}/**/*.{ts,tsx}": [
"eslint --fix --max-warnings=0",
"prettier --write"
],
"*.{ts,tsx}": [
"prettier --write"
],
"src/test/**/*.{test,integration.test}.{ts,tsx}": [
"prettier --write"
],
"*.{json,css,md,mdc}": [
"prettier --write"
],
"eslint.config.js": [
"prettier --write"
],
"commitlint.config.js": [
"prettier --write"
]
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/montserrat": "^5.2.8",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-icons": "^5.7.0",
"react-router": "^8.2.0",
"react-toastify": "^11.1.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"prettier": "^3.9.5",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22.22.1"
},
"packageManager": "npm@11.16.0"
}

31
playwright.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
// Port 4173 isolates Playwright from the app's normal port 3000 server.
baseURL: "http://localhost:4173",
// Pin the browser timezone to UTC so date-to-ISO assertions in the default
// create flow are deterministic regardless of host locale. DST-transition
// scenarios override this with their own non-UTC context.
timezoneId: "UTC",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev -- --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

BIN
public/BrandingImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "Work Orders",
"name": "Sea Haven - Work Orders",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#1c75bc",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

108
public/seahaven-logo.svg Normal file
View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Seahaven Industries — official client wordmark. viewBox cropped to the
artwork bounds (original art sits in a middle band of a 100x100 canvas);
aspect ratio preserved, nothing distorted. -->
<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="9 40 81 22" xml:space="preserve">
<title>Seahaven Industries</title>
<path fill="#A8D6EF" d="M6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1z"/>
<g>
<path fill="#2BABE2" d="M19.4,43.8c-0.5-0.3-1-0.5-1.6-0.6c-0.6-0.1-1.2-0.2-1.8,0c-0.1,0-0.2,0.1-0.3,0.1
c-0.1,0.1-0.2,0.2-0.2,0.3c0,0.2,0.1,0.3,0.2,0.3c0.3,0.1,0.5,0.1,0.8,0.2c0.6,0.1,1.2,0.2,1.7,0.3c0.5,0.1,1,0.3,1.5,0.7
c0.5,0.4,0.8,0.9,0.8,1.6c0.1,1.5-0.5,2.6-1.8,3.4c-0.6,0.3-1.2,0.5-1.9,0.6c-0.8,0.1-1.6,0.1-2.4-0.1c-0.8-0.2-1.6-0.5-2.3-1
c-0.1-0.1-0.3-0.2-0.4-0.3c0.1-0.3,0.3-0.5,0.4-0.7c0.1-0.3,0.3-0.5,0.4-0.8c0.1-0.2,0.3-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
c1.1,0.7,2.2,1,3.5,0.9c0.2,0,0.4-0.1,0.6-0.2c0.1,0,0.2-0.1,0.2-0.2c0.2-0.2,0.1-0.4-0.1-0.6c-0.2-0.1-0.4-0.1-0.6-0.2
c-0.5-0.1-0.9-0.2-1.4-0.2c-0.5-0.1-0.9-0.2-1.3-0.4c-0.3-0.1-0.6-0.3-0.8-0.5c-0.6-0.5-0.8-1.1-0.8-1.9c0.1-1.3,0.7-2.2,1.8-2.8
c0.5-0.3,1-0.5,1.6-0.5c1.1-0.2,2.2-0.1,3.2,0.2c0.4,0.1,0.7,0.3,1,0.5c0.2,0.1,0.3,0.2,0.5,0.4C20.2,42.4,19.8,43.1,19.4,43.8z"/>
<g>
<path fill="#2A2627" d="M84.1,54.2c0.4,0.3,0.7,0.4,1.2,0.3c0.1,0,0.3,0,0.3-0.2c-0.1-0.1-0.2-0.1-0.3-0.2c-0.2,0-0.3-0.1-0.5-0.1
c-0.1,0-0.2-0.1-0.3-0.1c-0.5-0.2-0.7-0.7-0.4-1.2c0.1-0.3,0.3-0.4,0.6-0.5c0.6-0.2,1.1-0.2,1.7,0.1c0,0,0.1,0,0.1,0.1
c0,0,0,0,0.1,0.1c0,0.1-0.1,0.2-0.2,0.3c-0.1,0.1-0.1,0.2-0.2,0.3C85.8,53,85.4,52.9,85,53c0,0-0.1,0.1-0.1,0.1
c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.7,0.5,1.3c-0.1,0.2-0.3,0.4-0.5,0.5c-0.7,0.3-1.5,0.2-2.1-0.3
c0.1-0.1,0.1-0.2,0.2-0.4C84,54.5,84.1,54.4,84.1,54.2z"/>
<path fill="#2A2627" d="M82.6,52.3c0,0.2,0,0.4-0.1,0.6c0,0.1,0,0.1-0.1,0.2c-0.4,0-0.7,0-1.1,0c0,0.1,0,0.2-0.1,0.3
c0.3,0,0.6,0,0.9,0c0.1,0.3-0.1,0.5-0.1,0.7c-0.3,0-0.6,0-0.9,0c0,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.1,0,0.2c0,0,0.1,0,0.1,0
c0.2,0,0.5,0,0.7,0c0.1,0,0.2,0,0.3,0c0.1,0.2,0,0.3,0,0.4c0,0.1,0,0.2-0.1,0.4c-0.2,0-0.4,0-0.7,0c-0.2,0-0.4,0-0.7,0
c-0.2,0-0.4,0-0.7,0c0-0.5,0.1-1,0.2-1.5c0.1-0.5,0.1-1,0.2-1.5C80.9,52.2,82.3,52.2,82.6,52.3z"/>
<g>
<path fill="#252122" d="M78.6,40.8c0.9,0,1.8,0,2.8,0c1,1.7,2,3.4,3,5.2c0-0.1,0.1-0.1,0.1-0.2c0.2-1.5,0.4-3,0.5-4.4
c0-0.2,0-0.4,0.1-0.6c0.4-0.1,2.4,0,2.7,0c0,0.3-1,8.8-1.2,9.6c-0.4,0.1-0.9,0-1.4,0c-0.5,0-0.9,0-1.4,0c-0.5-0.9-1-1.7-1.5-2.6
c-0.5-0.9-1-1.7-1.5-2.6c-0.1,0.2-0.1,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5
c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3,0,0.5c0,0.2,0,0.3-0.1,0.5
c0,0.2,0,0.3-0.1,0.5c-0.4,0.1-2.4,0.1-2.7,0C77.4,50.1,78.5,41.5,78.6,40.8z"/>
<path fill="#252122" d="M39.5,50.5c0-0.1,0-0.2,0-0.3c0.4-3.1,0.8-6.2,1.1-9.3c0.2,0,0.5,0,0.7,0c0.2,0,0.5,0,0.7,0
c0.2,0,0.4,0,0.7,0c0.2,0,0.4,0,0.7,0c0,0.6-0.1,1.2-0.2,1.8c-0.1,0.6-0.1,1.2-0.2,1.7c0,0,0,0,0,0c0,0,0,0,0,0c1,0,2,0,3,0
c0,0,0.1,0,0.1,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0,0,0,0c0,0,0,0,0,0c0.1,0,0.2,0,0.4,0c0.8,0,1.5,0,2.3,0c0,0.1,0,0.2,0,0.2
c-0.1,0.8-0.2,1.6-0.3,2.4c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.7-0.2,1.5-0.3,2.2c0,0.1,0,0.2,0,0.2
c-0.3,0.1-2.4,0.1-2.8,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0-0.1,0-0.1c0,0,0,0,0,0c-0.9,0-1.8,0-2.7,0c-0.1,0-0.2,0-0.3,0
c0,0,0,0-0.1,0c-0.1,1.2-0.3,2.3-0.4,3.5C41.4,50.5,40.5,50.5,39.5,50.5z"/>
<path fill="#252122" d="M54.2,40.8c0.9,0,1.7,0,2.6,0c0.2,0.4,2.8,9.3,2.8,9.6c0,0,0,0,0,0c0,0,0,0,0,0c-0.8,0-1.7,0-2.5,0
c-0.1,0-0.1,0-0.2,0c-0.2-0.5-0.3-1-0.4-1.5c-0.3-0.1-2.8-0.1-3.8,0c-0.3,0.5-0.5,1-0.8,1.6c-0.3,0.1-2.4,0.1-2.8,0c0,0,0,0,0,0
c0,0,0,0,0,0c1.7-3.2,3.4-6.3,5.1-9.5C54.1,40.9,54.1,40.8,54.2,40.8z M55.1,44.1C55.1,44.1,55,44.1,55.1,44.1
c-0.5,0.9-0.9,1.7-1.4,2.5c0,0,0,0.1,0,0.1c0.2,0,0.4,0,0.7,0c0.2,0,0.5,0,0.7,0c0.2,0,0.4,0,0.7,0C55.6,45.8,55.3,45,55.1,44.1z
"/>
<path fill="#2BABE2" d="M33.4,40.8c0.9,0,1.7,0,2.6,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0.1,0.1,0.2,0.1,0.3c0.9,3.1,1.8,6.1,2.7,9.2
c0,0.1-0.1,0.1-0.2,0.1c-0.8,0-1.6,0-2.5,0c0,0-0.1,0-0.1,0c-0.2-0.5-0.3-1.1-0.5-1.6c-0.3-0.1-3.3-0.1-3.8,0
c-0.3,0.5-0.5,1-0.8,1.5c-0.3,0.1-2.2,0.1-2.7,0c-0.1-0.1,0-0.2,0.1-0.2c0.5-1,1.1-2,1.6-3c0.9-1.7,1.9-3.5,2.8-5.2
C32.9,41.6,33.1,41.2,33.4,40.8z M35,46.7c-0.2-0.8-0.4-1.6-0.6-2.4c0-0.1-0.1-0.1-0.1-0.2c0,0-0.1,0.1-0.1,0.1
c-0.4,0.8-0.8,1.6-1.3,2.5c0,0,0,0,0,0.1C33.3,46.8,34.7,46.8,35,46.7z"/>
<path fill="#252122" d="M64.9,50.5c-0.9,0-1.8,0-2.7,0c0,0,0,0,0,0c0,0,0,0,0,0c-0.9-3.2-1.8-6.3-2.7-9.5c0,0,0-0.1,0-0.1
c0.3-0.1,2.8-0.1,3.2,0c0.5,1.9,0.9,3.8,1.4,5.7c0.3-0.4,0.4-0.8,0.6-1.2c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.7,0.5-1.1
c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.8,0.6-1.1c1.1,0,2.1,0,3.2,0c0,0.1-0.1,0.2-0.1,0.4c-1.1,2-2.1,4-3.2,6.1
c-0.5,1-1,1.9-1.5,2.9C65.1,50.2,65,50.3,64.9,50.5z"/>
<path fill="#252122" d="M76,50.4c-0.3,0.1-6,0.1-6.5,0c0-0.1,0-0.1,0-0.2c0.1-1,0.2-1.9,0.4-2.9c0.1-1,0.2-1.9,0.3-2.9
c0.1-1,0.2-1.9,0.4-2.9c0-0.3,0.1-0.5,0.1-0.8c0,0,0,0,0.1,0c0,0,0,0,0,0c2,0,4,0,6,0c0.1,0,0.2,0,0.3,0c0,0,0,0,0.1,0
c0,0.4-0.1,0.8-0.1,1.2c0,0.4-0.1,0.8-0.2,1.2c-0.1,0-0.2,0-0.3,0c-1,0-2,0-3,0c-0.1,0-0.3,0-0.4,0c0,0.1,0,0.2-0.1,0.3
c0,0.2-0.1,0.5-0.1,0.7c0.1,0.1,0.2,0.1,0.3,0.1c0.8,0,1.5,0,2.3,0c0.1,0,0.3,0,0.4,0c0,0.3-0.2,1.8-0.3,2.3c-0.1,0-0.2,0-0.3,0
c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0c-0.1,0.3-0.2,0.8-0.2,1.1c0.6,0,1.2,0,1.8,0c0.6,0,1.2,0,1.8,0c0,0,0,0.1,0,0.1
C76.2,48.8,76.1,49.6,76,50.4z"/>
<path fill="#2BABE2" d="M28.8,40.8C28.9,40.8,28.9,40.9,28.8,40.8c0,0.8-0.1,1.5-0.2,2.2c0,0.1,0,0.2-0.1,0.3
c-0.3,0.1-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0c-0.1,0.4-0.1,0.7-0.1,1c0.1,0.1,0.2,0.1,0.3,0.1
c0.7,0,1.5,0,2.2,0c0.1,0,0.3,0,0.4,0c0,0.3-0.1,1.7-0.3,2.3c-0.1,0-0.2,0-0.3,0c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0
c-0.1,0.4-0.1,0.7-0.1,1.1c0.3,0.1,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0,0.3-0.2,2-0.3,2.5
c0,0-0.1,0-0.1,0c-2.1,0-4.1,0-6.2,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0.4-3.2,0.8-6.4,1.2-9.6c0.1,0,0.2,0,0.3,0
c2,0,4,0,5.9,0C28.7,40.8,28.8,40.8,28.8,40.8z"/>
<path fill="#282425" d="M52,55.3c0.1-1,0.2-2,0.4-3c0.3,0,0.6,0,0.9,0c0.4,0.5,0.6,1,1,1.6c0.1-0.1,0.1-0.2,0.1-0.3
c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0.2,0,0.4,0,0.6,0c0.1,0,0.1,0,0.2,0
c0,0.3-0.2,1.9-0.3,3c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0c-0.3-0.5-0.6-1-0.9-1.6c-0.1,0.5-0.1,1-0.2,1.5
C52.6,55.3,52.3,55.3,52,55.3z"/>
<path fill="#282425" d="M56.6,55.3c0.1-1,0.2-2,0.4-3c0.1,0,0.2,0,0.2,0c0.3,0,0.7,0,1,0c0.2,0,0.4,0,0.7,0.1
c0.5,0.2,0.7,0.5,0.8,1c0.1,1-0.6,1.9-1.6,2C57.6,55.3,57.1,55.3,56.6,55.3z M57.6,54.5c0.1,0,0.2,0,0.3,0c0.4,0,0.6-0.2,0.8-0.5
c0.2-0.4-0.1-0.9-0.6-0.9c-0.1,0-0.2,0-0.4,0C57.7,53.5,57.7,54,57.6,54.5z"/>
<path fill="#282425" d="M73.8,52.2c0,0,0.1,0,0.1,0c0.4,0,0.8,0,1.1,0c0.1,0,0.3,0,0.4,0c0.5,0.1,0.7,0.5,0.7,0.9
c0,0.4-0.2,0.7-0.5,1c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.3,0.4,0.6,0.6,1c-0.3,0.1-0.7,0-1,0c-0.2-0.3-0.3-0.7-0.6-1
c-0.1,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.2,0,0.4c0,0.1,0,0.3-0.1,0.4c-0.3,0-0.6,0-0.8,0C73.4,54.9,73.7,52.6,73.8,52.2z M74.4,53.6
c0.2,0,0.3,0,0.5,0c0.1,0,0.2-0.1,0.2-0.1c0.1-0.1,0.1-0.2,0.1-0.4c0-0.1-0.1-0.2-0.2-0.2c-0.2,0-0.3,0-0.5,0
C74.5,53.2,74.5,53.4,74.4,53.6z"/>
<path fill="#292627" d="M61.3,52.2c0.3,0,0.6,0,0.9,0c0.1,0.2,0,0.3,0,0.4c-0.1,0.4-0.1,0.9-0.2,1.3c0,0.1,0,0.2,0,0.2
c0,0.3,0.3,0.4,0.6,0.3c0.2-0.1,0.4-0.2,0.4-0.5c0-0.2,0.1-0.4,0.1-0.5c0.1-0.4,0.1-0.8,0.2-1.3c0.2,0,0.3,0,0.5,0
c0.1,0,0.3,0,0.4,0c0,0.1,0,0.1,0,0.1c-0.1,0.6-0.1,1.1-0.2,1.7c-0.1,0.9-0.8,1.3-1.5,1.3c-0.1,0-0.2,0-0.4,0
c-0.7-0.1-1.1-0.6-1.1-1.3c0-0.5,0.1-0.9,0.2-1.4C61.3,52.5,61.3,52.4,61.3,52.2z"/>
<path fill="#2A2627" d="M66.6,53.1c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.8,0.5,1.3
c-0.1,0.3-0.3,0.5-0.6,0.5c-0.7,0.2-1.3,0.2-2-0.2c0,0-0.1-0.1-0.1-0.1c0.1-0.2,0.2-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
c0.3,0.1,0.5,0.2,0.8,0.2c0.1,0,0.1,0,0.2,0c0.1,0,0.1-0.1,0.1-0.1c0-0.1,0-0.1-0.1-0.1c-0.1,0-0.3-0.1-0.4-0.1
c-0.2-0.1-0.4-0.1-0.6-0.2c-0.4-0.2-0.6-0.6-0.4-1.1c0.1-0.3,0.4-0.5,0.7-0.7c0.5-0.2,1-0.2,1.5,0c0.1,0,0.1,0.1,0.2,0.1
c0,0,0.1,0.1,0.1,0.1c-0.1,0.2-0.2,0.4-0.4,0.6c-0.1,0-0.1,0-0.2-0.1c-0.3-0.1-0.5-0.1-0.8-0.1C66.7,53,66.6,53,66.6,53.1z"/>
<path fill="#292626" d="M72.2,52.3c0,0.2,0,0.5-0.1,0.8c-0.2,0.1-0.5,0-0.8,0c-0.1,0.4-0.1,0.7-0.2,1.1c-0.1,0.4-0.1,0.7-0.1,1.1
c-0.3,0-0.6,0-0.9,0c0-0.4,0.1-0.7,0.1-1.1c0-0.4,0.1-0.7,0.1-1.1c-0.3-0.1-0.5,0-0.7,0c-0.1-0.3,0-0.5,0.1-0.8
C70,52.2,71.8,52.2,72.2,52.3z"/>
<path fill="#292627" d="M49.9,52.2c0.3,0,0.5,0,0.8,0c0,0.5-0.1,1-0.2,1.5c0,0.5-0.1,1-0.2,1.5c-0.3,0-0.6,0-0.8,0
C49.5,55,49.7,52.8,49.9,52.2z"/>
<path fill="#292526" d="M78,52.2c0.3,0,0.6,0,0.8,0c0,0.1,0,0.1,0,0.2c-0.1,0.6-0.1,1.1-0.2,1.7c0,0.4-0.1,0.7-0.1,1.1
c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.5,0C77.7,54.3,77.8,53.3,78,52.2z"/>
<g>
<path fill="#60B3E3" d="M42.6,55.2c-0.9,0.1-2,0.1-3.4,0.5c-0.7,0.2-2,0.4-3.5,1.1c-0.7,0.3-0.5,0.3-2.2,1.2
c-1.2,0.6-2,1.1-3.1,1.5c-1.3,0.6-2,0.9-2.8,1c-0.8,0.2-2.1,0.3-3.5,0c-0.5-0.1-1.1-0.2-1.9-0.6c-0.9-0.5-1.5-1.1-1.8-1.5
c0.2,0,0.4,0,0.7,0c1-0.1,2.3-0.1,3.7-0.6c0.4-0.1,0.8-0.3,1.6-0.6c0.1,0,0.8-0.3,1.6-0.7c1.9-0.9,3.9-1.9,4-2
c0,0,3.2-1.7,5-2.6c0.2-0.1,0.6-0.3,1.2-0.3c0.3,0,0.6,0,0.9,0c0.6,0,1.4,0.1,2.4,0.3c1.2,0.3,2,0.7,2.4,0.9
c0.2,0.1,1,0.6,2,1.4c0.5,0.4,0.8,0.7,1.1,1C45.3,55.1,43.7,55.1,42.6,55.2z"/>
<path fill="#2BABE2" d="M11.5,55c1.1-0.3,1.5-0.3,1.5-0.3c0.5-0.1,0.9-0.1,1.2-0.1c0.8,0,1.4,0,1.7,0.1c1,0.1,1.8,0.3,2.2,0.5
c0.4,0.1,1,0.4,2.3,0.9c0.7,0.3,0.8,0.3,1.1,0.4c0.4,0.1,1.1,0.4,1.9,0.5c0.6,0.1,1.6,0.3,3,0.2c1.2-0.1,2-0.4,2.2-0.5
c0.4-0.2,0.7-0.3,1.4-0.7c1.1-0.6,1.7-1,2.8-1.7c1.5-0.9,2.2-1.4,2.9-1.7c1.1-0.5,1.9-0.8,1.9-0.9c0-0.1-0.8,0-1.3,0.1
c-0.7,0.2-1.2,0.3-1.2,0.4c-1.1,0.4-2.4,0.8-3.6,1.1c-1.4,0.3-2.4,0.6-3.8,0.6c-1.6,0-2.8-0.3-3.5-0.5c-1-0.2-1.1-0.4-2.7-0.8
c-1.1-0.3-1.6-0.4-2.2-0.5c-0.6-0.1-1.7-0.2-3.1,0.1c-0.5,0.1-1.2,0.3-2,0.7C12.6,53.7,11.5,55,11.5,55z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

55
scripts/deploy-web.sh Executable file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
#
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
#
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
# the right cache headers, and invalidates CloudFront.
#
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
# so it has no hardcoded resource IDs.
set -euo pipefail
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
REGION="${AWS_REGION:-us-east-1}"
echo "Building SPA (VITE_API_URL comes from .env.production)..."
npm ci
npm run build
echo "Reading stack outputs from ${STACK_NAME}..."
stack_output() {
aws cloudformation describe-stacks \
--stack-name "${STACK_NAME}" \
--region "${REGION}" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
exit 1
fi
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
# Everything except index.html: long-lived + immutable, prune stale objects.
aws s3 sync dist/ "s3://${BUCKET}/" \
--delete \
--exclude "index.html" \
--cache-control "public,max-age=31536000,immutable"
echo "Uploading index.html (never cached)..."
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
echo "Invalidating CloudFront ${DIST_ID}..."
aws cloudfront create-invalidation \
--distribution-id "${DIST_ID}" \
--paths "/*"
echo "Web deploy complete."

View file

@ -0,0 +1,17 @@
{
"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)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
}
]
}

View file

@ -0,0 +1,293 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, 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(
(relativePath) => isGoverned(relativePath) && existsSync(path.join(ROOT, relativePath)),
);
}
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" };
}
// Invoke eslint via node so Windows (no shebang exec) and Unix both work.
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
const result = spawnSync(
process.execPath,
[
eslintJs,
...files,
...ruleArgs,
"--max-warnings=0",
"--no-warn-ignored",
"--no-error-on-unmatched-pattern",
],
{ cwd: ROOT, encoding: "utf8" },
);
return {
skipped: false,
status: result.status,
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() ?? "",
files,
};
}
function plural(count, word) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
if (!baseRef) {
failures.push(
"base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
try {
gitText(["merge-base", baseRef, "HEAD"]);
} catch {
failures.push(
`base ref '${baseRef}' cannot be resolved against HEAD. Fetch it or set GOVERNANCE_BASE correctly.`,
);
}
}
console.log("─".repeat(64));
console.log("godfile ratchet: legacy caps may only shrink");
const god = godfileRatchet(baseRef);
console.log(
` cap: ${god.cap} lines | grandfathered debt: ${plural(god.remaining.length, "file")} | new violations: ${god.newDebt.length}`,
);
for (const entry of god.remaining) {
console.log(` debt ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
for (const entry of god.newDebt) {
console.log(` NEW ${String(entry.lines).padStart(4)} ${entry.path}`);
}
for (const entry of god.grownDebt) {
console.log(` GREW ${String(entry.lines).padStart(4)}/${entry.maxLines} ${entry.path}`);
}
if (god.newDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.newDebt.length, "file")} exceed ${god.cap} lines. Refactor them under the cap; new baseline debt is forbidden.`,
);
}
if (god.grownDebt.length > 0) {
failures.push(
`godfile ratchet: ${plural(god.grownDebt.length, "grandfathered file")} exceeded its frozen line cap.`,
);
}
if (god.baselineLoosening.length > 0) {
failures.push(
`governance baseline was loosened: ${god.baselineLoosening.join("; ")}. Only cap reductions and entry removals are allowed.`,
);
}
if (!god.comparedBaseline) {
console.log(" baseline comparison unavailable (initial adoption or missing base file)");
}
if (god.stale.length > 0) {
console.log(` stale baseline entries (now compliant — remove to ratchet tighter):`);
for (const entry of god.stale) {
console.log(` stale ${entry.path}`);
}
}
console.log("─".repeat(64));
if (!baseRef) {
console.log("changed-file maintainability gate: FAIL (no valid base ref)");
} else {
const files = changedGovernedFiles(baseRef);
console.log(
`changed-file maintainability gate (base: ${baseRef}): ${files === null ? "unresolvable" : plural(files.length, "changed governed file")}`,
);
if (files === null) {
console.log(" failed — base ref could not be resolved against HEAD");
} else {
const gate = maintainabilityGate(files);
if (gate.skipped) {
console.log(` skipped — ${gate.reason}`);
} else {
const clean = gate.status === 0;
console.log(
` result: ${clean ? "PASS" : "FAIL"} (complexity<=20, function<=150 lines, params<=4, depth<=4)`,
);
if (!clean) {
if (gate.stdout) console.log(gate.stdout);
if (gate.stderr) console.log(gate.stderr);
failures.push(
"changed-file maintainability gate: see ESLint output above. Extract functions/components to meet the thresholds; do not relax the thresholds.",
);
}
}
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
for (const failure of failures) console.log(` - ${failure}`);
process.exit(1);
}
console.log("RESULT: PASS — all governance gates green");
}
main();

View file

@ -0,0 +1,224 @@
import http from "node:http";
const port = Number(process.env.VENDOR_MOCK_PORT ?? 5142);
let nextCompanyId = 103;
let nextTechnicianId = 3;
const companies = [
{
companyId: 101,
rowVersion: "rv-101-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "(314) 555-0198",
email: "adam@gateway.test",
preferredContact: "Email",
tradeSpecialties: "Plumbing",
isActive: true,
totalJobs: 72,
},
],
},
{
companyId: 102,
rowVersion: "rv-102-1",
name: "Metro HVAC",
companyPhone: "(314) 555-0110",
email: "dispatch@metro.test",
address: "2 Pine St",
city: "Clayton",
state: "MO",
zip: "63105",
googleMapsUrl: "",
notes: "",
technicians: [],
},
];
function json(response, body, status = 200) {
response.writeHead(status, {
"content-type": "application/json",
"access-control-allow-origin": "*",
});
response.end(JSON.stringify(body));
}
async function readBody(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
}
function findCompany(url) {
const vendorId = url.searchParams.get("vendorId");
const companyId = url.searchParams.get("companyId");
if (vendorId) {
return companies.find((company) =>
company.technicians.some((technician) => String(technician.id) === vendorId),
);
}
return companies.find((company) => String(company.companyId) === companyId);
}
function companyFacet(company) {
return {
companyId: company.companyId,
name: company.name,
companyPhone: company.companyPhone,
email: company.email,
address: company.address,
city: company.city,
state: company.state,
zip: company.zip,
googleMapsUrl: company.googleMapsUrl,
};
}
function directoryRows() {
return companies.flatMap((company) =>
company.technicians.length
? company.technicians.map((technician) => ({
Id: technician.id,
CompanyId: company.companyId,
CompanyName: company.name,
ContactName: technician.contactName,
Email: technician.email,
Phone: technician.phone,
CompanyPhone: company.companyPhone,
City: company.city,
State: company.state,
TradeSpecialties: technician.tradeSpecialties,
GoogleMapsUrl: company.googleMapsUrl,
Notes: company.notes,
TotalJobs: technician.totalJobs,
IsActive: technician.isActive,
PreferredContact: technician.preferredContact,
}))
: [
{
Id: null,
CompanyId: company.companyId,
CompanyName: company.name,
ContactName: "",
Email: company.email,
Phone: "",
CompanyPhone: company.companyPhone,
City: company.city,
State: company.state,
TradeSpecialties: "",
GoogleMapsUrl: company.googleMapsUrl,
Notes: company.notes,
TotalJobs: 0,
IsActive: true,
PreferredContact: "Phone",
},
],
);
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
if (request.method === "OPTIONS") return json(response, {});
if (url.pathname === "/api/Vendor/GetVendorList") {
const search = (url.searchParams.get("search") ?? "").toLowerCase();
const active = url.searchParams.get("isActive") !== "false";
const rows = directoryRows().filter(
(row) =>
row.IsActive === active &&
(!search ||
`${row.CompanyName} ${row.ContactName} ${row.TradeSpecialties} ${row.City} ${row.State}`
.toLowerCase()
.includes(search)),
);
return json(response, { Data: rows, TotalCount: rows.length, PageNumber: 1, PageSize: 12 });
}
if (url.pathname === "/api/Vendor/facets") {
return json(response, {
data: {
companies: companies.map(companyFacet),
trades: ["Plumbing", "HVAC"],
locations: companies.map((company) => ({
city: company.city,
state: company.state,
label: `${company.city}, ${company.state}`,
})),
jobBuckets: [],
},
});
}
if (url.pathname === "/api/vendor-company-roster" && request.method === "GET") {
const company = findCompany(url);
return company ? json(response, company) : json(response, { message: "Not found" }, 404);
}
if (url.pathname === "/api/vendor-company-roster" && request.method === "POST") {
const body = await readBody(request);
const company = {
...body,
companyId: nextCompanyId++,
rowVersion: `rv-${Date.now()}`,
technicians: (body.technicians ?? []).map((technician) => ({
...technician,
id: nextTechnicianId++,
totalJobs: 0,
})),
};
companies.push(company);
return json(response, company);
}
const rosterMatch = url.pathname.match(/^\/api\/vendor-company-roster\/(\d+)$/);
if (rosterMatch && request.method === "PUT") {
const company = companies.find((item) => item.companyId === Number(rosterMatch[1]));
if (!company) return json(response, { message: "Not found" }, 404);
const body = await readBody(request);
Object.assign(company, body, {
companyId: company.companyId,
rowVersion: `rv-${Date.now()}`,
technicians: (body.technicians ?? []).map((technician) => ({
...technician,
id: technician.id ?? nextTechnicianId++,
totalJobs:
company.technicians.find((existing) => existing.id === technician.id)?.totalJobs ?? 0,
})),
});
return json(response, company);
}
const portalMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/portal-token$/);
if (portalMatch) {
return json(response, {
data: {
portalUrl: "https://vendor.example.test/token",
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
},
});
}
const impactMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/deactivation-impact$/);
if (impactMatch) {
return json(response, {
data: { vendorId: Number(impactMatch[1]), canDeactivate: true, openWorkOrders: [] },
});
}
return json(response, { message: `No mock for ${request.method} ${url.pathname}` }, 404);
});
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`Vendor E2E mock listening on http://127.0.0.1:${port}\n`);
});

55
src/api/api-error.ts Normal file
View file

@ -0,0 +1,55 @@
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
if (status === 401) {
return "You are not authorized to access this page.";
}
if (status === 403) {
return "You do not have permission to perform this action.";
}
if (status === 404) {
return "The requested resource was not found.";
}
if (status === 500) {
return "A server error occurred. Please try again later.";
}
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
const message = record.message ?? record.Message ?? record.error;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return "An error occurred";
}
export function normalizeApiRequestError(error: Error): Error {
if (isHTTPError(error)) {
error.message = mapHttpStatusToMessage(error.response.status, error.data);
return error;
}
if (isNetworkError(error)) {
error.message = "No response from server. Please check your connection.";
return error;
}
if (isTimeoutError(error)) {
error.message = "The request timed out. Please try again.";
return error;
}
return error;
}

174
src/api/api-paths.ts Normal file
View file

@ -0,0 +1,174 @@
export const API_PATHS = {
authentication: {
login: "Authentication/Login",
changePassword: "Authentication/ChangePassword",
forgetPassword: "Authentication/ForgetPassword",
verificationCode: "Authentication/VerificationCode",
resetPassword: "Authentication/ResetPassword",
},
workOrder: {
getList: "WorkOrder/GetWorkOrderList",
getById: "WorkOrder/GetWorkorderById",
create: "WorkOrder/Create",
update: "WorkOrder/Update",
delete: "WorkOrder/DeleteWorkOrder",
addWorkorder: "WorkOrder/AddWorkorder",
editWorkorder: "WorkOrder/EditWorkorder",
deleteWorkorder: "WorkOrder/DeleteWorkorder",
addCommentJson: "WorkOrder/AddCommentJson",
changeAssignment: "WorkOrder/ChangeAssignment",
updateChecklistItem: "WorkOrder/UpdateChecklistItem",
addChecklistItem: "WorkOrder/AddChecklistItem",
verifyDispatch: "WorkOrder/VerifyDispatch",
addDispatchSignoff: "WorkOrder/AddDispatchSignoff",
getDispatch: "WorkOrder/GetDispatch",
updateDispatch: "WorkOrder/UpdateDispatch",
addDispatchComment: "WorkOrder/AddDispatchComment",
dispatchToVendor: "WorkOrder/DispatchToVendor",
changeStatus: "WorkOrder/ChangeStatus",
board: "workorders/board",
boardCreate: "workorders/board",
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
media: (id: string | number) => `workorders/${id}/media`,
mediaItem: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
lookupsDispatchers: "workorders/lookups/dispatchers",
},
pmSchedule: {
getList: "PMSchedule/GetList",
getById: "PMSchedule/GetById",
save: "PMSchedule/Save",
delete: "PMSchedule/Delete",
},
followUp: {
getList: "FollowUp/GetFollowUpList",
getById: "FollowUp/GetById",
create: "FollowUp/Create",
update: "FollowUp/Update",
delete: "FollowUp/Delete",
complete: "FollowUp/Complete",
cancel: "FollowUp/Cancel",
save: "FollowUp/Save",
},
employee: {
getList: "Employee/GetEmployeeList",
getById: "Employee/GetById",
create: "Employee/Create",
update: "Employee/Update",
delete: "Employee/Delete",
createEmployee: "Employee/CreateEmployee",
updateEmployee: "Employee/UpdateEmployee",
deleteEmployee: "Employee/DeleteEmployee",
addEmployees: "Employee/AddEmployees",
editEmployees: "Employee/EditEmployees",
deleteEmployees: "Employee/DeleteEmployees",
dropdown: "Employee/Dropdown",
},
contact: {
getList: "Contact/GetContactList",
getContacts: "Contact/GetContacts",
create: "Contact/Create",
update: "Contact/Update",
delete: "Contact/Delete",
addContacts: "Contact/AddContacts",
editContacts: "Contact/EditContacts",
deleteContacts: "Contact/DeleteContacts",
dropdown: "Contact/Dropdown",
getLocations: "Contact/GetLocations",
},
location: {
getList: "Location/GetLocationList",
getById: "Location/GetById",
create: "Location/Create",
update: "Location/Update",
delete: "Location/Delete",
deleteLocation: "Location/DeleteLocation",
dropdown: "Location/Dropdown",
sites: "locations/sites",
},
account: {
getList: "Account/GetAccountList",
create: "Account/Create",
update: "Account/Update",
delete: "Account/Delete",
addAccount: "Account/AddAccount",
editAccount: "Account/EditAccount",
deleteAccount: "Account/DeleteAccount",
addAccounts: "Account/AddAccounts",
editAccounts: "Account/EditAccounts",
deleteAccounts: "Account/DeleteAccounts",
dropdown: "Account/Dropdown",
},
asset: {
getList: "Asset/GetAssetList",
getById: "Asset/GetById",
getAssetById: "Asset/GetAssetById",
create: "Asset/Create",
update: "Asset/Update",
delete: "Asset/Delete",
deleteAsset: "Asset/DeleteAsset",
addAsset: "Asset/AddAsset",
editAsset: "Asset/EditAsset",
},
vendor: {
getList: "Vendor/GetVendorList",
getById: "Vendor/GetById",
create: "Vendor/Create",
update: "Vendor/Update",
delete: "Vendor/Delete",
dropdown: "vendors/Dropdown",
facets: "Vendor/facets",
deactivationImpact: "deactivation-impact",
},
vendorCompanyRoster: {
base: "vendor-company-roster",
byCompany: (companyId: string | number) => `vendor-company-roster/${companyId}`,
},
calendar: {
getEventsForCalendar: "Calendar/GetEventsForCalendar",
getEventList: "Calendar/GetEventList",
getEventById: "Calendar/GetEventById",
addEvent: "Calendar/AddEvent",
editEvent: "Calendar/EditEvent",
deleteEvent: "Calendar/DeleteEvent",
},
dashboard: {
stats: "Dashboard/Stats",
},
dropdownOptions: {
byCategory: "DropdownOptions/ByCategory",
create: "DropdownOptions/Create",
update: "DropdownOptions/Update",
delete: "DropdownOptions",
},
user: {
list: "User",
},
taskListTemplate: {
list: "TaskListTemplate",
},
vendorPortal: {
base: "vendor-portal",
session: "/session",
dispatches: "/dispatches",
},
rest: {
employees: "employees",
contacts: "contacts",
accounts: "accounts",
locations: "locations",
workOrders: "workorders",
pmSchedules: "pmschedules",
followups: "followups",
vendors: "vendors",
taskListTemplates: "tasklisttemplates",
uplifts: "uplifts",
dispatches: "dispatches",
},
} as const;

118
src/api/api.ts Normal file
View file

@ -0,0 +1,118 @@
import ky, { type KyInstance, type Options } from "ky";
import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void {
clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
}
}
function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
}
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
export function shouldStripJsonContentType(body: unknown): boolean {
return body instanceof FormData;
}
export const api: KyInstance = ky.create({
prefix: resolveApiPrefixUrl(env.apiUrl),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
({ request, options }) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
// Let the runtime set multipart boundary; default JSON Content-Type breaks FormData.
if (shouldStripJsonContentType(options.body)) {
request.headers.delete("Content-Type");
}
},
],
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
return response;
}
clearSessionAndRedirectToLogin();
return response;
},
],
},
});
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
return api.get(url, options).json<T>();
}
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.post(url, { ...options, json: body }).json<T>();
}
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
return api.post(url, { ...options, body }).json<T>();
}
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.put(url, { ...options, json: body }).json<T>();
}
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.patch(url, { ...options, json: body }).json<T>();
}
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
return api.delete(url, options).json<T>();
}
export async function apiPostNoContent(
url: string,
body?: unknown,
options?: Options,
): Promise<void> {
const response = await api.post(url, { ...options, json: body });
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
}
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
const response = await api.delete(url, options);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
}
export { mapHttpStatusToMessage };

View file

@ -0,0 +1,62 @@
import { readItems, readPage, readPageSize, readTotalCount } from "@/api/paginated-response-fields";
export interface PaginatedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function handleApiResponse<T>(data: unknown): T {
if (Array.isArray(data)) {
return data as T;
}
if (isRecord(data)) {
const nested = data.data ?? data.Data ?? data.value;
if (nested !== undefined) {
return nested as T;
}
}
return data as T;
}
export function handlePaginatedResponse<T>(
data: unknown,
params?: { page?: number; pageSize?: number },
): PaginatedResult<T> {
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? 12;
if (Array.isArray(data)) {
return {
items: data as T[],
totalCount: data.length,
page,
pageSize,
};
}
if (isRecord(data)) {
const items = readItems<T>(data);
return {
items,
totalCount: readTotalCount(data, items.length),
page: readPage(data, page),
pageSize: readPageSize(data, pageSize),
};
}
return {
items: [],
totalCount: 0,
page,
pageSize,
};
}

View file

@ -0,0 +1,36 @@
/** Field readers for paginated API payloads that tolerate PascalCase/camelCase and nested envelopes. */
export function readItems<T>(record: Record<string, unknown>): T[] {
const nested = record.data ?? record.Data ?? record.value;
const itemsFromPaged =
(Array.isArray(record.items) ? (record.items as T[]) : undefined) ??
(Array.isArray(record.Items) ? (record.Items as T[]) : undefined);
return itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []);
}
export function readTotalCount(record: Record<string, unknown>, fallback: number): number {
return (
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
(typeof record.Count === "number" ? record.Count : undefined) ??
fallback
);
}
export function readPage(record: Record<string, unknown>, fallback: number): number {
return (
(typeof record.page === "number" ? record.page : undefined) ??
(typeof record.pageNumber === "number" ? record.pageNumber : undefined) ??
(typeof record.PageNumber === "number" ? record.PageNumber : undefined) ??
fallback
);
}
export function readPageSize(record: Record<string, unknown>, fallback: number): number {
return (
(typeof record.pageSize === "number" ? record.pageSize : undefined) ??
(typeof record.PageSize === "number" ? record.PageSize : undefined) ??
fallback
);
}

5
src/app-root.tsx Normal file
View file

@ -0,0 +1,5 @@
import { Routes } from "@/app-routes";
export function AppRoot() {
return <Routes />;
}

1
src/app-routes.tsx Normal file
View file

@ -0,0 +1 @@
export { Routes } from "@/routing";

View file

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

View file

@ -0,0 +1,34 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type AuthPageShellProps = ComponentPropsWithoutRef<"div"> & {
cardClassName?: string;
children: ReactNode;
};
export function AuthPageShell({
children,
className,
cardClassName,
...props
}: AuthPageShellProps) {
return (
<div
className={cn(
"flex min-h-screen w-full items-center justify-center bg-(--color-bg-page) px-4",
className,
)}
{...props}
>
<div
className={cn(
"w-full max-w-sm rounded-(--radius-lg) border border-border bg-card p-10! shadow-sm",
cardClassName,
)}
>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,96 @@
import type {
FieldErrors,
UseFormHandleSubmit,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { Box } from "@mui/material";
import { Button } from "@/components/ui/button";
import { FormErrorMessage } from "@/components/ui/form-error-message";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { TextField } from "@/components/ui/text-field";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
export type LoginFormProps = {
register: UseFormRegister<LoginFormValues>;
handleSubmit: UseFormHandleSubmit<LoginFormValues>;
watch: UseFormWatch<LoginFormValues>;
errors: FieldErrors<LoginFormValues>;
onSubmit: (values: LoginFormValues) => void;
isLoggingIn: boolean;
loginError: Error | null;
};
export function LoginForm({
register,
handleSubmit,
watch,
errors,
onSubmit,
isLoggingIn,
loginError,
}: LoginFormProps) {
const [username, password] = watch(["username", "password"]);
const isSubmitDisabled = !loginSchema.safeParse({ username, password }).success;
return (
<Box
component="form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
autoComplete="off"
>
<FormField
label="Email"
htmlFor="login-email"
error={Boolean(errors.username)}
helperText={errors.username?.message}
appearance="auth"
>
<TextField
{...register("username")}
id="login-email"
type="email"
autoComplete="email"
placeholder="you@seahavenind.com"
disabled={isLoggingIn}
error={Boolean(errors.username)}
/>
</FormField>
<FormField
label="Password"
htmlFor="login-password"
error={Boolean(errors.password)}
helperText={errors.password?.message}
appearance="auth"
>
<PasswordField
{...register("password")}
id="login-password"
autoComplete="current-password"
placeholder="••••••••"
disabled={isLoggingIn}
error={Boolean(errors.password)}
/>
{Boolean(loginError) && (
<FormErrorMessage message="Invalid email or password. Please try again." />
)}
</FormField>
<Button
type="submit"
fullWidth
size="lg"
loading={isLoggingIn}
loadingText="Signing in…"
disabled={isSubmitDisabled}
className="mt-1!"
>
Sign In
</Button>
</Box>
);
}

View file

@ -0,0 +1,11 @@
import { Outlet } from "react-router";
import { GuestRoute } from "@/components/auth/guest-route";
export default function AuthLayout() {
return (
<GuestRoute>
<Outlet />
</GuestRoute>
);
}

50
src/app/(auth)/login.tsx Normal file
View file

@ -0,0 +1,50 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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";
export default function LoginPage() {
const { login, isLoggingIn, loginError } = useAuthContext();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
return (
<AuthPageShell>
<BrandLockup className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm
register={register}
handleSubmit={handleSubmit}
watch={watch}
errors={errors}
onSubmit={login}
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<Text
variant="caption"
tone="muted"
className="mt-5! block text-center text-(length:--text-xs)"
>
Having trouble? Contact your administrator.
</Text>
</AuthPageShell>
);
}

View file

@ -0,0 +1,36 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = getSidebarWidth(navCollapsed);
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} />
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
</Box>
</Box>
</ProtectedRoute>
);
}

View file

@ -0,0 +1,194 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { accountSchema, type AccountFormValues } from "@/domain/accounts/schemas/account-schema";
import { useAccountDetail } from "@/domain/accounts/use-cases/use-account-detail";
import { useCreateAccount } from "@/domain/accounts/use-cases/use-create-account";
import { useUpdateAccount } from "@/domain/accounts/use-cases/use-update-account";
const defaultValues: AccountFormValues = {
AccountName: "",
AccountNumber: "",
Industry: "",
Website: "",
Notes: "",
Address1: "",
Address2: "",
City: "",
State: "",
Zip: "",
Country: "US",
};
export default function AccountFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: account, isLoading, error } = useAccountDetail(id);
const createAccount = useCreateAccount();
const updateAccount = useUpdateAccount();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<AccountFormValues>({
resolver: zodResolver(accountSchema),
defaultValues,
});
useEffect(() => {
if (account && isEdit) {
reset({
AccountName: account.AccountName ?? "",
AccountNumber: account.AccountNumber ?? "",
Industry: account.Industry ?? "",
Website: account.Website ?? "",
Notes: account.Notes ?? "",
Address1: account.Address1 ?? "",
Address2: account.Address2 ?? "",
City: account.City ?? "",
State: account.State ?? "",
Zip: account.Zip ?? "",
Country: account.Country ?? "US",
});
}
}, [account, isEdit, reset]);
const isSaving = createAccount.isPending || updateAccount.isPending;
const onSubmit = (values: AccountFormValues) => {
if (isEdit && id) {
updateAccount.mutate({ id, values });
return;
}
createAccount.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Account" : "Create an Account"}
</Typography>
<Button variant="text" onClick={() => navigate("/accounts")}>
Back to list
</Button>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load account"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
General Details
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("AccountName")}
label="Account Name"
required
error={Boolean(errors.AccountName)}
helperText={errors.AccountName?.message}
fullWidth
/>
<TextField {...register("AccountNumber")} label="Account Number" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("Industry")} label="Industry" fullWidth />
<TextField {...register("Website")} label="Website" fullWidth />
</Stack>
<TextField {...register("Notes")} label="Notes" fullWidth multiline minRows={2} />
</Stack>
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Address
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("Address1")} label="Address 1" fullWidth />
<TextField {...register("Address2")} label="Address 2" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("City")} label="City" fullWidth />
<TextField {...register("State")} label="State / Province" fullWidth />
<TextField {...register("Zip")} label="Zip/Postal Code" fullWidth />
</Stack>
<Controller
name="Country"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel id="country-label">Country</InputLabel>
<Select
{...field}
labelId="country-label"
label="Country"
value={field.value ?? "US"}
>
<MenuItem value="">Select Country</MenuItem>
<MenuItem value="US">United States</MenuItem>
</Select>
</FormControl>
)}
/>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/accounts")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Account" : "Create Account"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/account-form";

View file

@ -0,0 +1,242 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useDeleteAccount } from "@/domain/accounts/use-cases/use-delete-account";
import { useDebounce } from "@/hooks/use-debounce";
export default function AccountsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<AccountListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteAccount = useDeleteAccount();
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
}),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useAccountsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
deleteAccount.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
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">
Accounts
</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 accounts..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/accounts/new")}>
+ New Account
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Number</TableCell>
<TableCell className="font-semibold">Industry</TableCell>
<TableCell className="font-semibold">Website</TableCell>
<TableCell className="font-semibold">Primary Contact</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Owner</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No accounts found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/accounts/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.number || "—"}</TableCell>
<TableCell>{row.industry || "—"}</TableCell>
<TableCell>{row.website || "—"}</TableCell>
<TableCell>{row.primaryContact || "—"}</TableCell>
<TableCell>{row.phoneNumber || "—"}</TableCell>
<TableCell>{row.owner || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit account"
onClick={() => navigate(`/accounts/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete account"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon 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]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Account</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAccount.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteAccount.isPending}
>
{deleteAccount.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/account-form";

View file

@ -0,0 +1,218 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { assetSchema, type AssetFormValues } from "@/domain/assets/schemas/asset-schema";
import { useAssetDetail } from "@/domain/assets/use-cases/use-asset-detail";
import { useAssetFormOptions } from "@/domain/assets/use-cases/use-asset-form-options";
import { useCreateAsset } from "@/domain/assets/use-cases/use-create-asset";
import { useUpdateAsset } from "@/domain/assets/use-cases/use-update-asset";
const defaultValues: AssetFormValues = {
assetCode: "",
name: "",
description: "",
accountId: null,
locationId: null,
isActive: true,
};
export default function AssetFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: asset, isLoading, error } = useAssetDetail(id);
const { accounts, sites, isLoading: optionsLoading, error: optionsError } = useAssetFormOptions();
const createAsset = useCreateAsset();
const updateAsset = useUpdateAsset();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<AssetFormValues>({
resolver: zodResolver(assetSchema),
defaultValues,
});
useEffect(() => {
if (asset && isEdit) {
reset({
assetCode: asset.assetCode,
name: asset.name,
description: asset.description,
accountId: asset.accountId,
locationId: asset.locationId,
isActive: asset.isActive,
});
}
}, [asset, isEdit, reset]);
const isSaving = createAsset.isPending || updateAsset.isPending;
const onSubmit = (values: AssetFormValues) => {
if (isEdit && id) {
updateAsset.mutate({ id, values });
return;
}
createAsset.mutate(values);
};
if ((isEdit && isLoading) || optionsLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
{Boolean(error || optionsError) && (
<Alert severity="error">
{error instanceof Error
? error.message
: optionsError instanceof Error
? optionsError.message
: "Failed to load asset"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Asset Code"
required
fullWidth
error={Boolean(errors.assetCode)}
helperText={errors.assetCode?.message}
{...register("assetCode")}
/>
<TextField
label="Name"
required
fullWidth
error={Boolean(errors.name)}
helperText={errors.name?.message}
{...register("name")}
/>
</Stack>
<TextField
label="Description"
fullWidth
multiline
rows={3}
{...register("description")}
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Account</InputLabel>
<Select
label="Account"
value={field.value ?? ""}
onChange={(event) => {
const value = String(event.target.value);
field.onChange(value === "" ? null : Number(value));
}}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{accounts.map((account) => (
<MenuItem key={String(account.id)} value={account.id}>
{account.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="locationId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select
label="Location"
value={field.value ?? ""}
onChange={(event) => {
const value = String(event.target.value);
field.onChange(value === "" ? null : Number(value));
}}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{sites.map((site) => (
<MenuItem key={String(site.id)} value={site.id}>
{site.title ? `${site.title} - ${site.name}` : site.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} />}
label="Active"
/>
)}
/>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/asset-form";

View file

@ -0,0 +1,244 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { AssetListItem } from "@/domain/assets/types/asset";
import { useAssetsList } from "@/domain/assets/use-cases/use-assets-list";
import { useDeleteAsset } from "@/domain/assets/use-cases/use-delete-asset";
import { useDebounce } from "@/hooks/use-debounce";
export default function AssetsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<AssetListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteAsset = useDeleteAsset();
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
}),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useAssetsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteAsset.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
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">
Assets
</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 assets..."
className="min-w-sidebar"
/>
<Button variant="contained" onClick={() => navigate("/assets/new")}>
+ New Asset
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Asset Code</TableCell>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Description</TableCell>
<TableCell className="font-semibold">Account</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No assets found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.assetCode || "—"}</TableCell>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/assets/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.description || "—"}</TableCell>
<TableCell>{row.accountName || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>
<Chip
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
size="small"
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit asset"
onClick={() => navigate(`/assets/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete asset"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon 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]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Asset</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAsset.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/asset-form";

View file

@ -0,0 +1,27 @@
import { Alert, Box, Typography } from "@mui/material";
export function CalendarUnavailablePanel() {
return (
<Box className="mx-auto max-w-3xl">
<Alert severity="warning" className="mb-4">
Calendário indisponível — a API de calendário ainda não está implementada no backend.
</Alert>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
As rotas permanecem reservadas para quando o CalendarController for disponibilizado.
</Typography>
</Box>
);
}
export default function CalendarUnavailablePage() {
return (
<Box className="p-4">
<CalendarUnavailablePanel />
</Box>
);
}

View file

@ -0,0 +1,289 @@
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams, useSearchParams } from "react-router";
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CALENDAR_REMINDER_OPTIONS,
CALENDAR_TIME_OPTIONS,
} from "@/components/common/calendar/calendar-utils";
import {
calendarEventSchema,
type CalendarEventFormValues,
} from "@/domain/calendar/schemas/calendar-event-schema";
import { useCalendarEventDetail } from "@/domain/calendar/use-cases/use-calendar-event-detail";
import { useCreateCalendarEvent } from "@/domain/calendar/use-cases/use-create-calendar-event";
import { useUpdateCalendarEvent } from "@/domain/calendar/use-cases/use-update-calendar-event";
import { toDateInputValue } from "@/lib/time-utils";
const COLORS = ["primary", "success", "info", "warning", "danger"] as const;
const defaultValues: CalendarEventFormValues = {
title: "",
description: "",
location: "",
recordId: null,
recordName: "",
recordTargetType: "",
reminderMinutes: 15,
eventColor: "primary",
startDate: new Date().toISOString().split("T")[0],
startTime: "08:00 AM",
endDate: new Date().toISOString().split("T")[0],
endTime: "08:30 AM",
allDay: false,
};
export default function EventFormPage() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: event, isLoading, error } = useCalendarEventDetail(id);
const createEvent = useCreateCalendarEvent();
const updateEvent = useUpdateCalendarEvent();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<CalendarEventFormValues>({
resolver: zodResolver(calendarEventSchema),
defaultValues: {
...defaultValues,
startDate: searchParams.get("date") ?? defaultValues.startDate,
endDate: searchParams.get("date") ?? defaultValues.endDate,
},
});
const allDay = useWatch({ control, name: "allDay" });
useEffect(() => {
if (event && isEdit) {
reset({
title: event.title,
description: event.description,
location: event.location,
recordId: event.recordId,
recordName: event.recordName,
recordTargetType: event.recordTargetType,
reminderMinutes: event.reminderMinutes,
eventColor: event.eventColor,
startDate: toDateInputValue(event.startDate),
startTime: event.startTime || "08:00 AM",
endDate: toDateInputValue(event.endDate),
endTime: event.endTime || "08:30 AM",
allDay: event.allDay,
});
}
}, [event, isEdit, reset]);
const isSaving = createEvent.isPending || updateEvent.isPending;
const onSubmit = (values: CalendarEventFormValues) => {
if (isEdit && id) {
updateEvent.mutate({ id, values });
return;
}
createEvent.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
{Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<TextField
label="Title"
required
fullWidth
error={Boolean(errors.title)}
helperText={errors.title?.message}
{...register("title")}
slotProps={{
htmlInput: { maxLength: 100 },
}}
/>
<TextField
label="Description"
fullWidth
multiline
rows={3}
{...register("description")}
/>
<TextField
label="Location"
fullWidth
{...register("location")}
slotProps={{
htmlInput: { maxLength: 150 },
}}
/>
<Controller
name="reminderMinutes"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Remind Attendees</InputLabel>
<Select
label="Remind Attendees"
value={field.value}
onChange={(event) => field.onChange(Number(event.target.value))}
>
{CALENDAR_REMINDER_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="eventColor"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Calendar Color</InputLabel>
<Select label="Calendar Color" value={field.value} onChange={field.onChange}>
{COLORS.map((color) => (
<MenuItem key={color} value={color}>
{color}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
{!allDay && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Start Date"
type="date"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
error={Boolean(errors.startDate)}
helperText={errors.startDate?.message}
{...register("startDate")}
/>
<Controller
name="startTime"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Start Time</InputLabel>
<Select label="Start Time" value={field.value} onChange={field.onChange}>
{CALENDAR_TIME_OPTIONS.map((time) => (
<MenuItem key={time} value={time}>
{time}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
)}
{!allDay && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="End Date"
type="date"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
error={Boolean(errors.endDate)}
helperText={errors.endDate?.message}
{...register("endDate")}
/>
<Controller
name="endTime"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>End Time</InputLabel>
<Select label="End Time" value={field.value} onChange={field.onChange}>
{CALENDAR_TIME_OPTIONS.map((time) => (
<MenuItem key={time} value={time}>
{time}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
)}
<Controller
name="allDay"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="All day event"
/>
)}
/>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/calendar")}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,13 @@
import { Outlet } from "react-router";
import { Alert, Box } from "@mui/material";
export default function CalendarLayout() {
return (
<Box className="flex flex-col gap-4">
<Alert severity="warning" className="mx-4 mt-4">
Calendário indisponível — a API de calendário ainda não está implementada no backend.
</Alert>
<Outlet />
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/calendar-unavailable";

View file

@ -0,0 +1 @@
export { default } from "./_components/calendar-unavailable";

View file

@ -0,0 +1 @@
export { default } from "./_components/calendar-unavailable";

View file

@ -0,0 +1,265 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { contactSchema, type ContactFormValues } from "@/domain/contacts/schemas/contact-schema";
import { useContactDetail } from "@/domain/contacts/use-cases/use-contact-detail";
import { useCreateContact } from "@/domain/contacts/use-cases/use-create-contact";
import { useUpdateContact } from "@/domain/contacts/use-cases/use-update-contact";
const defaultValues: ContactFormValues = {
firstName: "",
middleName: "",
lastName: "",
title: "",
contactType: "",
gender: "",
dateOfBirth: "",
territory: "-1",
owner: "1",
privacySetting: "PublicReadWrite",
about: "",
tags: "",
street: "",
streetLineTwo: "",
city: "",
zip: "",
country: "US",
state: "",
county: "",
timeZone: "Eastern Standard Time",
smsAddress: "",
website: "",
facebookUrl: "",
linkedInUrl: "",
twitterUrl: "",
};
const CONTACT_TYPES = ["Corporate", "Store Mgr", "Facilities", "Area Leader"];
const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"];
export default function ContactFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: contact, isLoading, error } = useContactDetail(id);
const createContact = useCreateContact();
const updateContact = useUpdateContact();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues,
});
useEffect(() => {
if (contact && isEdit) {
reset({
firstName: contact.firstName,
middleName: contact.middleName,
lastName: contact.lastName,
title: contact.title,
contactType: contact.contactType,
gender: contact.gender,
dateOfBirth: contact.dateOfBirth,
territory: contact.territory || "-1",
owner: contact.owner || "1",
privacySetting: contact.privacySetting,
about: contact.about,
tags: contact.tags,
street: contact.street,
streetLineTwo: contact.streetLineTwo,
city: contact.city,
zip: contact.zip,
country: contact.country,
state: contact.state,
county: contact.county,
timeZone: contact.timeZone,
smsAddress: contact.smsAddress,
website: contact.website,
facebookUrl: contact.facebookUrl,
linkedInUrl: contact.linkedInUrl,
twitterUrl: contact.twitterUrl,
});
}
}, [contact, isEdit, reset]);
const isSaving = createContact.isPending || updateContact.isPending;
const onSubmit = (values: ContactFormValues) => {
if (isEdit && id) {
updateContact.mutate({ id, values });
return;
}
createContact.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contact"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<Typography variant="subtitle2" className="font-semibold uppercase">
General Details
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="First Name"
required
fullWidth
error={Boolean(errors.firstName)}
helperText={errors.firstName?.message}
{...register("firstName")}
/>
<TextField label="Middle Name" fullWidth {...register("middleName")} />
<TextField
label="Last Name"
required
fullWidth
error={Boolean(errors.lastName)}
helperText={errors.lastName?.message}
{...register("lastName")}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField label="Title" fullWidth {...register("title")} />
<FormControl fullWidth>
<InputLabel>Contact Type</InputLabel>
<Select label="Contact Type" defaultValue="" {...register("contactType")}>
<MenuItem value="">Select type</MenuItem>
{CONTACT_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<FormControl fullWidth>
<InputLabel>Territory</InputLabel>
<Select label="Territory" required defaultValue="-1" {...register("territory")}>
<MenuItem value="">Select territory</MenuItem>
<MenuItem value="-1">No Territory</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Owner</InputLabel>
<Select label="Owner" required defaultValue="1" {...register("owner")}>
<MenuItem value="">Select owner</MenuItem>
<MenuItem value="1">Employee 1</MenuItem>
<MenuItem value="2">Employee 2</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Privacy</InputLabel>
<Select
label="Privacy"
defaultValue="PublicReadWrite"
{...register("privacySetting")}
>
<MenuItem value="PublicReadWrite">Public read/write</MenuItem>
<MenuItem value="PublicReadOnly">Public read only</MenuItem>
<MenuItem value="Private">Private</MenuItem>
</Select>
</FormControl>
</Stack>
<TextField label="About" fullWidth {...register("about")} />
<Typography variant="subtitle2" className="font-semibold uppercase">
Address
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField label="Address 1" fullWidth {...register("street")} />
<TextField label="Address 2" fullWidth {...register("streetLineTwo")} />
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField label="City" fullWidth {...register("city")} />
<TextField label="Zip" fullWidth {...register("zip")} />
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<FormControl fullWidth>
<InputLabel>State</InputLabel>
<Select label="State" defaultValue="" {...register("state")}>
<MenuItem value="">Select state</MenuItem>
{US_STATES.map((st) => (
<MenuItem key={st} value={st}>
{st}
</MenuItem>
))}
</Select>
</FormControl>
<TextField label="Country" fullWidth {...register("country")} />
</Stack>
<Typography variant="subtitle2" className="font-semibold uppercase">
Additional
</Typography>
<TextField label="Website" fullWidth placeholder="http://" {...register("website")} />
<TextField label="SMS Address" fullWidth {...register("smsAddress")} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/contacts")}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/contact-form";

View file

@ -0,0 +1,221 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { ContactListItem } from "@/domain/contacts/types/contact";
import { useContactsList } from "@/domain/contacts/use-cases/use-contacts-list";
import { useDeleteContact } from "@/domain/contacts/use-cases/use-delete-contact";
import { useDebounce } from "@/hooks/use-debounce";
export default function ContactsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<ContactListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteContact = useDeleteContact();
const listParams = useMemo(
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useContactsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
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">
Contacts
</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={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search contacts..."
className="min-w-sidebar"
/>
<Button variant="contained" onClick={() => navigate("/contacts/new")}>
+ New Contact
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contacts"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Account</TableCell>
<TableCell className="font-semibold">Title</TableCell>
<TableCell className="font-semibold">Email</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Owner</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No contacts found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/contacts/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.account || "—"}</TableCell>
<TableCell>{row.title || "—"}</TableCell>
<TableCell>{row.email || "—"}</TableCell>
<TableCell>{row.phoneNumber || "—"}</TableCell>
<TableCell>{row.owner || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`/contacts/edit/${row.id}`)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Contact</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteContact.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
disabled={deleteContact.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteContact.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/contact-form";

View file

@ -0,0 +1,240 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
Alert,
Box,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
interface KpiCardProps {
title: string;
description?: string;
value?: string | number;
icon: ReactNode;
color: "success" | "primary" | "info" | "warning";
onClick: () => void;
loading?: boolean;
}
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
const colorMap = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
} as const;
return (
<Paper
variant="outlined"
onClick={onClick}
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
sx={{
borderLeftWidth: 4,
borderLeftStyle: "solid",
borderLeftColor: colorMap[color],
}}
>
<Box className="flex w-full gap-4 p-4">
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col justify-center gap-1">
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
{value !== undefined && (
<Typography variant="h4" component="p" className="font-bold leading-tight">
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{Boolean(description) && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
</Box>
</Box>
</Paper>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
icon={<AddIcon />}
color="success"
onClick={() => navigate("/workorders/new")}
/>
<KpiCard
title="Open Work Orders"
value={formatStat(stats?.open)}
icon={<FolderOpenOutlinedIcon />}
color="primary"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Not Dispatched"
value={formatStat(stats?.notDispatched)}
icon={<ScheduleOutlinedIcon />}
color="info"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Completed Orders"
value={formatStat(stats?.completed)}
icon={<CheckCircleOutlineIcon />}
color="warning"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
<TableCell align="right" className="font-semibold">
0-14 Days
</TableCell>
<TableCell align="right" className="font-semibold">
15-29 Days
</TableCell>
<TableCell align="right" className="font-semibold">
30-59 Days
</TableCell>
<TableCell align="right" className="font-semibold">
60+ Days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{PRIORITY_LEVELS.map((priority) => (
<TableRow key={priority}>
<TableCell>{priority}</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Invoice Analysis
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Count
</TableCell>
<TableCell align="right" className="font-semibold">
Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{INVOICE_STATUSES.map((status) => (
<TableRow key={status}>
<TableCell>{status}</TableCell>
<TableCell align="right">0</TableCell>
<TableCell align="right">$0.00</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,378 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
CircularProgress,
Divider,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
resolveJobTitleId,
resolveJobTitleLabel,
} from "@/domain/employees/lib/resolve-job-title-id";
import {
employeeDefaultValues,
employeeSchema,
type EmployeeFormValues,
} from "@/domain/employees/schemas/employee-schema";
import { useCreateEmployee } from "@/domain/employees/use-cases/use-create-employee";
import { useEmployeeDetail } from "@/domain/employees/use-cases/use-employee-detail";
import { useUpdateEmployee } from "@/domain/employees/use-cases/use-update-employee";
import { JobTitleSelect } from "@/app/(protected)/employees/_components/job-title-select";
const DEPARTMENTS = ["Administration", "Sales", "Service", "Field Service"] as const;
const TIME_ZONES = [
"Eastern Standard Time",
"Central Standard Time",
"Mountain Standard Time",
"Pacific Standard Time",
] as const;
const ROLES = [
"Admin",
"Administrator User",
"Dispatcher",
"Editor User",
"Employee",
"Read Only User",
"Technician",
] as const;
const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"] as const;
function SelectField({
name,
label,
control,
options,
required,
error,
helperText,
}: {
name: keyof EmployeeFormValues;
label: string;
control: ReturnType<typeof useForm<EmployeeFormValues>>["control"];
options: readonly string[];
required?: boolean;
error?: boolean;
helperText?: string;
}) {
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<FormControl fullWidth required={required} error={error}>
<InputLabel id={`${name}-label`}>{label}</InputLabel>
<Select {...field} labelId={`${name}-label`} label={label} value={field.value ?? ""}>
<MenuItem value="">
<em>Select...</em>
</MenuItem>
{options.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
{Boolean(helperText) && (
<Typography variant="caption" color="error" className="mt-1">
{helperText}
</Typography>
)}
</FormControl>
)}
/>
);
}
export default function EmployeeFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: employee, isLoading, error } = useEmployeeDetail(id);
const createEmployee = useCreateEmployee();
const updateEmployee = useUpdateEmployee();
const {
register,
handleSubmit,
reset,
control,
setValue,
formState: { errors },
} = useForm<EmployeeFormValues>({
resolver: zodResolver(employeeSchema),
defaultValues: employeeDefaultValues,
});
useEffect(() => {
if (employee && isEdit) {
const jobTitleId = resolveJobTitleId(employee.jobTitleId, employee.jobTitle);
const jobTitle = resolveJobTitleLabel(jobTitleId, employee.jobTitle);
reset({
firstName: employee.firstName,
middleName: employee.middleName,
lastName: employee.lastName,
email: employee.email,
phone: employee.phone,
jobTitle,
jobTitleId,
role: employee.role,
status: employee.status,
hireDate: employee.hireDate,
dateOfBirth: employee.dateOfBirth,
address: employee.address,
department: employee.department,
timeZone: employee.timeZone,
gender: employee.gender,
employeeNumber: employee.employeeNumber,
regionName: employee.regionName,
userName: employee.userName,
confirmUserName: employee.confirmUserName,
street: employee.street,
streetLineTwo: employee.streetLineTwo,
city: employee.city,
zip: employee.zip,
country: employee.country,
state: employee.state,
county: employee.county,
smsAddress: employee.smsAddress,
website: employee.website,
facebookUrl: employee.facebookUrl,
linkedInUrl: employee.linkedInUrl,
twitterUrl: employee.twitterUrl,
});
}
}, [employee, isEdit, reset]);
const isSaving = createEmployee.isPending || updateEmployee.isPending;
const onSubmit = (values: EmployeeFormValues) => {
if (isEdit && id) {
updateEmployee.mutate({ id, values });
return;
}
createEmployee.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-5xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit an Employee" : "Create an Employee"}
</Typography>
<Button variant="text" onClick={() => navigate("/employees")}>
Back to list
</Button>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employee"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Typography variant="subtitle1" className="font-semibold">
General Details
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("firstName")}
label="First Name"
required
error={Boolean(errors.firstName)}
helperText={errors.firstName?.message}
fullWidth
/>
<TextField {...register("middleName")} label="Middle Name" fullWidth />
<TextField
{...register("lastName")}
label="Last Name"
required
error={Boolean(errors.lastName)}
helperText={errors.lastName?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<SelectField
name="department"
label="Department"
control={control}
options={DEPARTMENTS}
/>
<SelectField
name="timeZone"
label="Time Zone"
control={control}
options={TIME_ZONES}
required
error={Boolean(errors.timeZone)}
helperText={errors.timeZone?.message}
/>
<SelectField
name="gender"
label="Gender"
control={control}
options={["Male", "Female", "Not specified", "Other"]}
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("dateOfBirth")}
label="Date of Birth"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<JobTitleSelect control={control} setValue={setValue} />
<TextField {...register("employeeNumber")} label="Employee #" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("hireDate")}
label="Hire Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<SelectField
name="regionName"
label="Region Name"
control={control}
options={["Region 1", "Region 2"]}
/>
</Stack>
<Divider />
<Typography variant="subtitle1" className="font-semibold">
Login &amp; Role
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{isEdit
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in."
: "A confirmation email will be sent to activate the Employee's account."}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<SelectField
name="role"
label="Role"
control={control}
options={ROLES}
required
error={Boolean(errors.role)}
helperText={errors.role?.message}
/>
<TextField
{...register("userName")}
label="User Name"
type="email"
required
error={Boolean(errors.userName)}
helperText={errors.userName?.message}
fullWidth
/>
<TextField
{...register("confirmUserName")}
label="Confirm User Name"
type="email"
required
error={Boolean(errors.confirmUserName)}
helperText={errors.confirmUserName?.message}
fullWidth
/>
</Stack>
<Divider />
<Typography variant="subtitle1" className="font-semibold">
Address
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("street")} label="Address 1" fullWidth />
<TextField {...register("streetLineTwo")} label="Address 2" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("city")} label="City" fullWidth />
<TextField {...register("zip")} label="Zip/Postal Code" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<SelectField name="country" label="Country" control={control} options={["US"]} />
<SelectField
name="state"
label="State / Province"
control={control}
options={US_STATES}
/>
<TextField {...register("county")} label="County" fullWidth />
</Stack>
<Divider />
<Typography variant="subtitle1" className="font-semibold">
Additional Details
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("smsAddress")} label="SMS Address" type="email" fullWidth />
<TextField {...register("website")} label="Website" fullWidth />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("facebookUrl")} label="Facebook" fullWidth />
<TextField {...register("linkedInUrl")} label="LinkedIn" fullWidth />
<TextField {...register("twitterUrl")} label="Twitter" fullWidth />
</Stack>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/employees")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,44 @@
import { Controller, type Control, type UseFormSetValue } from "react-hook-form";
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
import { JOB_TITLE_OPTIONS } from "@/domain/employees/constants/job-title-options";
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
interface JobTitleSelectProps {
control: Control<EmployeeFormValues>;
setValue: UseFormSetValue<EmployeeFormValues>;
}
export function JobTitleSelect({ control, setValue }: JobTitleSelectProps) {
return (
<Controller
name="jobTitleId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel id="jobTitleId-label">Job Title</InputLabel>
<Select
labelId="jobTitleId-label"
label="Job Title"
value={field.value ?? ""}
onChange={(event) => {
const value = String(event.target.value);
const nextId = value === "" ? null : Number(value);
field.onChange(nextId);
const label = JOB_TITLE_OPTIONS.find((option) => option.id === nextId)?.label ?? "";
setValue("jobTitle", label, { shouldDirty: true });
}}
>
<MenuItem value="">
<em>Select...</em>
</MenuItem>
{JOB_TITLE_OPTIONS.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/employee-form";

View file

@ -0,0 +1,253 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { EmployeeListItem } from "@/domain/employees/types/employee";
import { useDeleteEmployee } from "@/domain/employees/use-cases/use-delete-employee";
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
import { useDebounce } from "@/hooks/use-debounce";
const STATUS_COLORS: Record<string, "default" | "success" | "warning" | "error"> = {
Active: "success",
Inactive: "default",
};
export default function EmployeesListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<EmployeeListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteEmployee = useDeleteEmployee();
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
}),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useEmployeesList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
deleteEmployee.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
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">
Employees
</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 employees..."
className="min-w-sidebar"
/>
<Button variant="contained" onClick={() => navigate("/employees/new")}>
+ New Employee
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employees"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Job Title</TableCell>
<TableCell className="font-semibold">Email</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Department</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No employees found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/employees/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.jobTitle || "—"}</TableCell>
<TableCell>{row.email || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.department || "—"}</TableCell>
<TableCell>
<Chip
size="small"
label={row.status || "—"}
color={STATUS_COLORS[row.status] ?? "default"}
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit employee"
onClick={() => navigate(`/employees/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete employee"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon 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]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Employee</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteEmployee.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteEmployee.isPending}
>
{deleteEmployee.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/employee-form";

View file

@ -0,0 +1,245 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
import {
followUpSchema,
type FollowUpFormValues,
} from "@/domain/follow-ups/schemas/follow-up-schema";
import { useCreateFollowUp } from "@/domain/follow-ups/use-cases/use-create-follow-up";
import { useFollowUpDetail } from "@/domain/follow-ups/use-cases/use-follow-up-detail";
import { useUpdateFollowUp } from "@/domain/follow-ups/use-cases/use-update-follow-up";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
const defaultValues: FollowUpFormValues = {
reason: "",
scheduleDate: "",
scheduleStartTime: "09:00",
employeeId: null,
accountId: null,
locationId: null,
workOrderId: null,
};
export default function FollowUpFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: followUp, isLoading, error } = useFollowUpDetail(id);
const { data: employeesData } = useEmployeesList({ page: 1, pageSize: 500 });
const { data: accountsData } = useAccountsList({ page: 1, pageSize: 500 });
const { data: locationsData } = useLocationsList({ page: 1, pageSize: 500 });
const createFollowUp = useCreateFollowUp();
const updateFollowUp = useUpdateFollowUp();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<FollowUpFormValues>({
resolver: zodResolver(followUpSchema),
defaultValues,
});
useEffect(() => {
if (followUp && isEdit) {
reset({
reason: followUp.reason,
scheduleDate: followUp.scheduleDate,
scheduleStartTime: followUp.scheduleStartTime,
employeeId: followUp.employeeId,
accountId: followUp.accountId,
locationId: followUp.locationId,
workOrderId: followUp.workOrderId,
});
}
}, [followUp, isEdit, reset]);
const employees = employeesData?.items ?? [];
const accounts = accountsData?.items ?? [];
const locations = locationsData?.items ?? [];
const isSaving = createFollowUp.isPending || updateFollowUp.isPending;
const onSubmit = (values: FollowUpFormValues) => {
if (isEdit && id) {
updateFollowUp.mutate({ id, values });
return;
}
createFollowUp.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-up"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<TextField
label="Reason"
required
fullWidth
multiline
minRows={2}
error={Boolean(errors.reason)}
helperText={errors.reason?.message}
{...register("reason")}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Schedule Date"
type="date"
required
fullWidth
error={Boolean(errors.scheduleDate)}
helperText={errors.scheduleDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
{...register("scheduleDate")}
/>
<TextField
label="Start Time"
type="time"
required
fullWidth
error={Boolean(errors.scheduleStartTime)}
helperText={errors.scheduleStartTime?.message}
slotProps={{ inputLabel: { shrink: true } }}
{...register("scheduleStartTime")}
/>
</Stack>
<Controller
name="employeeId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Assigned Employee</InputLabel>
<Select
label="Assigned Employee"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{employees.map((emp) => (
<MenuItem key={String(emp.id)} value={String(emp.id)}>
{emp.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Account</InputLabel>
<Select
label="Account"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{accounts.map((acc) => (
<MenuItem key={String(acc.id)} value={String(acc.id)}>
{acc.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="locationId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select
label="Location"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{locations.map((loc) => (
<MenuItem key={String(loc.id)} value={String(loc.id)}>
{loc.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/followups")}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/follow-up-form";

View file

@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
import { useDeleteFollowUp } from "@/domain/follow-ups/use-cases/use-delete-follow-up";
import { useFollowUpsList } from "@/domain/follow-ups/use-cases/use-follow-ups-list";
import { useDebounce } from "@/hooks/use-debounce";
import { toDateInputValue } from "@/lib/time-utils";
const STATUS_OPTIONS = ["Open", "Completed", "Cancelled", ""];
export default function FollowUpsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [status, setStatus] = useState("Open");
const [deleteTarget, setDeleteTarget] = useState<FollowUpListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteFollowUp = useDeleteFollowUp();
const listParams = useMemo(
() => ({ search: debouncedSearch, page: page + 1, pageSize, status }),
[debouncedSearch, page, pageSize, status],
);
const { data, isLoading, isFetching, error } = useFollowUpsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
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">
Follow-ups
</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={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search follow-ups..."
className="min-w-sidebar"
/>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<Button variant="contained" onClick={() => navigate("/followups/new")}>
+ New Follow-up
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Reason</TableCell>
<TableCell className="font-semibold">Schedule</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Account</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">WO #</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No follow-ups found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/followups/edit/${row.id}`)}
>
{row.reason || "—"}
</Button>
</TableCell>
<TableCell>
{toDateInputValue(row.scheduleDate) || "—"}
{row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""}
</TableCell>
<TableCell>{row.assignedTo || "—"}</TableCell>
<TableCell>{row.accountName || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>{row.internalWONumber || row.workOrderNumber || "—"}</TableCell>
<TableCell>
<Chip size="small" label={row.status || "—"} />
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => navigate(`/followups/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Follow-up</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to delete this follow-up?</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteFollowUp.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
disabled={deleteFollowUp.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteFollowUp.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

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