mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 16:13:12 +00:00
fix(work-orders): clear uplift form state only after successful mutations
This commit is contained in:
commit
380188afb7
655 changed files with 62823 additions and 0 deletions
52
.cursor/rules/seahaven-admin.mdc
Normal file
52
.cursor/rules/seahaven-admin.mdc
Normal 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
1
.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=
|
||||
5
.env.development
Normal file
5
.env.development
Normal 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
12
.env.example
Normal 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
4
.env.production
Normal 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
3
.github/CODEOWNERS
vendored
Normal 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
43
.github/workflows/ci.yaml
vendored
Normal 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
38
.github/workflows/deploy.yml
vendored
Normal 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
48
.gitignore
vendored
Normal 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
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx lint-staged
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal 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
15
.prettierrc
Normal 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
81
AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# AGENTS.md — frontend mandatory conventions
|
||||
|
||||
This file is the binding entry point for **every contributor — human or AI coding
|
||||
or review agent** — working in this repository (`seahaven-new-app`, the SHOC
|
||||
frontend). It makes the validated React/TypeScript conventions mandatory and
|
||||
points to the operational documents and executable gates that enforce them.
|
||||
|
||||
Read these before writing or reviewing code. They override generic "best
|
||||
practice" suggestions from any agent or model. This repository contract may
|
||||
strengthen, but never weaken, the workspace-level `AGENTS.md`.
|
||||
|
||||
- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single
|
||||
command, and the no-false-pass guarantees.
|
||||
- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the
|
||||
MUST / MUST NOT conventions (conditional rendering, typography, forms, state,
|
||||
data access, security, maintainability) with evidence and thresholds.
|
||||
- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review
|
||||
contract (exact-head review, board regression inventory, behavior-based
|
||||
testing, security/performance/Big-O review, no style-only comments).
|
||||
- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the
|
||||
detailed rationale for the conditional-rendering and typography rules.
|
||||
|
||||
## One command to run every gate
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
|
||||
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||
checks (`npm run governance`). **Do not claim a task is done until `npm run
|
||||
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
|
||||
`governance` job, so a green local run mirrors CI.
|
||||
|
||||
## Non-negotiable rules (enforced; do not work around)
|
||||
|
||||
These are already enforced by lint/build or the governance script. Disabling,
|
||||
baselining, or per-line-suppressing them is forbidden (see exceptions below).
|
||||
|
||||
- **No one-sided `cond ? <Element/> : null`** — use `&&` or the `when` prop.
|
||||
- **The left operand of `&&` in JSX must be entirely boolean** — coerce with
|
||||
`Boolean(...)` / an explicit comparison; `{count && ...}` is rejected.
|
||||
- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `<p>`/`<h*>` and
|
||||
the `vp-error` class outside `Text` are rejected.
|
||||
- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it,
|
||||
do not silence it.
|
||||
- **Hooks correctness** — the `react-hooks` recommended rules (including
|
||||
`exhaustive-deps`) run under the zero-warnings gate.
|
||||
- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt
|
||||
has a frozen per-file cap that may only decrease.
|
||||
- **Changed-file maintainability** — changed TS/TSX must meet
|
||||
`complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth.
|
||||
|
||||
## How to add / change a convention
|
||||
|
||||
1. Land it **green**: a new or tightened rule must ship with the codebase passing
|
||||
it (a migration in the same change), not as a warning-only backlog.
|
||||
2. If legacy would break, use changed-file enforcement or migrate it. Do not add
|
||||
new baseline debt or raise a frozen cap.
|
||||
3. Document the rule, its threshold, and its evidence in
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||
|
||||
## Exceptions and the ADR process
|
||||
|
||||
Exceptions are **not granted by disabling a rule inline**. To deviate:
|
||||
|
||||
1. Record an **Architecture Decision Record** under `docs/adr/`
|
||||
(`NNNN-title.md`: context, decision, consequences, alternatives).
|
||||
2. Existing grandfathered entries may only be removed or have their caps
|
||||
reduced. New entries and cap increases fail the governance gate.
|
||||
3. Get it reviewed like any other change. The ADR + baseline entry is the
|
||||
auditable record; an `eslint-disable` comment is not.
|
||||
|
||||
## Toolchain (do not change without an ADR)
|
||||
|
||||
React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form +
|
||||
Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via
|
||||
`packageManager`, invoked through corepack). **No new dependencies without an
|
||||
ADR** — prefer the libraries already established (see
|
||||
[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and
|
||||
data access).
|
||||
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
125
ARCHITECTURE_AND_CODE_QUALITY.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions
|
||||
|
||||
Mandatory React/TypeScript conventions for this repository. Each rule lists how
|
||||
it is enforced. Detailed rationale for rendering and typography lives in
|
||||
[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule
|
||||
says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD"
|
||||
means it is a review-enforced convention backed by the
|
||||
[PR review contract](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never
|
||||
`cond ? <Element/> : null`. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence
|
||||
with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string
|
||||
semantics and TypeScript narrowing with `count > 0`, `value != null`.
|
||||
`{count && <X/>}` renders `0` and is rejected by the type-aware
|
||||
`seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for
|
||||
the approved guard forms.)
|
||||
- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back
|
||||
to `ReactNode`; coerce with `Boolean(prop)` before `&&`.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings,
|
||||
descriptions, labels, captions, code, and async feedback. Raw `<p>` and
|
||||
`<h1>`–`<h6>` are rejected. (ESLint `no-restricted-syntax`.)
|
||||
- **MUST** use `Text variant="error"` for the `vp-error` styling; the
|
||||
`vp-error` class on any non-`Text` element is rejected
|
||||
(`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to
|
||||
bypass the static check.
|
||||
- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are
|
||||
omitted from `TextProps` so the variant contract (element, tone, live region)
|
||||
cannot be overridden. Use `as` and `tone`.
|
||||
- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text
|
||||
with `when`; mount `error` (assertive `alert`) on demand.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
- **MUST** use the libraries already established: **React Hook Form** for field
|
||||
registration/lifecycle, **Zod** for validation and inferred types, **TanStack
|
||||
Query** for server reads and mutations (pending/error state, invalidation,
|
||||
retries).
|
||||
- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A
|
||||
second form convention increases cognitive and dependency cost. Replacing
|
||||
React Hook Form requires an approved repo-wide migration ADR with measured
|
||||
benefits, a codemod/migration plan, and removal of the superseded dependency.
|
||||
- **SHOULD** keep file upload selection/validation in a focused component and
|
||||
drive upload progress/errors/retry through a TanStack Query mutation, not in a
|
||||
route-sized page.
|
||||
|
||||
## State ownership (bounded)
|
||||
|
||||
- **MUST** keep query loading/error/empty state adjacent to the query result;
|
||||
mutation pending/error state in the component that initiated the mutation.
|
||||
- **MUST** compose focused state components instead of accumulating unrelated
|
||||
booleans in a page. Route pages coordinate sections and navigation; reusable
|
||||
sections own their interaction details.
|
||||
- **SHOULD** render errors inline with accessible feedback (`Text
|
||||
variant="error"`); reserve toasts for cross-page outcomes.
|
||||
|
||||
## Data access
|
||||
|
||||
- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent
|
||||
entity + identity tuple, co-located with the query). Unstable or ad-hoc keys
|
||||
break caching and invalidation. (Review-enforced; a future lint rule is
|
||||
tracked as a gap.)
|
||||
- **MUST NOT** materialize large server collections into client state and then
|
||||
filter/sort them in the component when the server (or a memoized, virtualized
|
||||
layer) should own it. Prefer server-side filtering/pagination; if client-side
|
||||
is required, memoize and avoid re-filtering on every render. (Review-enforced.)
|
||||
- **MUST** invalidate the right query keys after a mutation so caches do not
|
||||
show stale data.
|
||||
|
||||
## Security
|
||||
|
||||
- **MUST NOT** surface raw server error payloads, stack traces, or internal
|
||||
identifiers to end users. Map server errors to a safe user-facing message
|
||||
(e.g. `Text variant="error"`); log the full detail only to controlled channels.
|
||||
(Review-enforced.)
|
||||
- **MUST NOT** bake secrets, tokens, or account-specific values into the build.
|
||||
Only `VITE_*` build-time vars are allowed, and they are baked into the bundle —
|
||||
never put a secret in a `VITE_` variable.
|
||||
|
||||
## Hooks, performance, and Big-O
|
||||
|
||||
- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`)
|
||||
under the zero-warnings gate.
|
||||
- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived
|
||||
data, key lists stably, and prefer server-side filtering for large sets
|
||||
(reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback`
|
||||
only when measured to matter (no speculative memoization).
|
||||
|
||||
## Maintainability (measured thresholds)
|
||||
|
||||
Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)).
|
||||
These are conservative code-shape proxies for review focus. File length is not
|
||||
proof of a god object, cyclomatic complexity is not runtime Big-O, and neither
|
||||
substitutes for behavior tests, profiling, query-plan evidence, or reviewer
|
||||
judgment.
|
||||
|
||||
| Metric | Threshold | How enforced |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------ |
|
||||
| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. |
|
||||
| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. |
|
||||
| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). |
|
||||
| Parameters | ≤ 4 | Changed-file ESLint `max-params`. |
|
||||
| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. |
|
||||
|
||||
**Why these are changed-file ratchets, not whole-repo errors:** measured against
|
||||
the current codebase, the maintainability thresholds surface **72 violations
|
||||
across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and
|
||||
**5 files exceed 500 lines**. Applying them repo-wide at `error` would block
|
||||
legacy without a migration. Instead:
|
||||
|
||||
- The **5 oversized files** are explicit grandfathered debt with frozen
|
||||
per-file caps in `scripts/governance-baseline.json`. They may shrink but never
|
||||
grow; new entries and cap increases fail.
|
||||
- The **complexity/function-length/params/depth** rules apply to **changed**
|
||||
TS/TSX on a PR/push, so new and modified code must comply while untouched
|
||||
legacy is not blocked. Bring legacy into compliance when you next touch it.
|
||||
|
||||
These thresholds are starting ratchets: tighten them (lower caps, fewer baseline
|
||||
entries) as debt is paid down. Never raise them to make a change pass.
|
||||
54
QUALITY_GATES.md
Normal file
54
QUALITY_GATES.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# QUALITY_GATES.md — executable frontend gates
|
||||
|
||||
The single command that runs **every** gate, locally and in CI:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only"
|
||||
backlog; rules ship green (see AGENTS.md → How to add a convention).
|
||||
- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports
|
||||
when type services are unavailable rather than silently claiming safety.
|
||||
- **Godfile ratchet is monotonic** — any new file over the cap, new baseline
|
||||
entry, global cap increase, per-file cap increase, or growth beyond a frozen
|
||||
legacy cap fails. Only cap reductions and entry removals are allowed.
|
||||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
188
README.md
Normal file
188
README.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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/)
|
||||
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
81
REVIEW_AND_PR_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# REVIEW_AND_PR_FRAMEWORK.md — PR review contract
|
||||
|
||||
Every PR is reviewed against this contract, by humans and by review agents. The
|
||||
goal is high-signal review: catch real behavior, security, performance, and
|
||||
regression problems — not re-lint what the gates already enforce.
|
||||
|
||||
## 1. Review the exact head
|
||||
|
||||
- **MUST** review the diff at the **current PR head**, not a stale checkout.
|
||||
Re-pull before reviewing if new commits landed; stale approvals are dismissed
|
||||
on push by branch protection.
|
||||
- **MUST** read the full diff of every changed file, including renames and
|
||||
generated/mapper code, not only the "interesting" components.
|
||||
|
||||
## 2. Board-backed regression inventory
|
||||
|
||||
- **MUST** check the change against the **Seahaven Jira SH board** inventory —
|
||||
all visible tickets for this area, not only the current ticket. Behavior that
|
||||
is Done / QA-approved / released is **protected scope**.
|
||||
- **MUST** treat a plausible regression against protected behavior as a
|
||||
**Blocker** until disproven with repo evidence (the accepting tests, the linked
|
||||
PR/release, and a targeted check of the changed code paths).
|
||||
- **MUST NOT** approve if board access or the relevant inventory is missing —
|
||||
say so and block rather than infer a pass. A missing ticket link alone is not
|
||||
a blocker, but known acceptance criteria must still be traced.
|
||||
|
||||
The current Jira workflow has no `Ready for QA` transition. Merged work remains
|
||||
in the documented pre-QA status until QA evidence supports `Done`; do not invent
|
||||
a status or mark unverified work Done.
|
||||
|
||||
## 3. Behavior-based testing
|
||||
|
||||
- **MUST** test through **public behavior** (rendered output, user interactions,
|
||||
query/mutation outcomes), not internal implementation details. Prefer
|
||||
`@testing-library` queries and user-event flows; assert what users observe.
|
||||
- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the
|
||||
governance script) executes — validate tooling by running the real gates
|
||||
(`npm run verify`), not with assertion-free unit tests.
|
||||
- **SHOULD** cover the meaningful branches of new logic: the happy path, the
|
||||
error/empty state, and any boundary the change introduces.
|
||||
|
||||
## 4. Security review
|
||||
|
||||
- **MUST** confirm no raw server error payloads, stack traces, or internal IDs
|
||||
leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security).
|
||||
- **MUST** confirm no secrets/tokens are introduced into the build, and that no
|
||||
`VITE_*` variable carries a secret (it is baked into the bundle).
|
||||
- **SHOULD** check untrusted input is validated (Zod) before use and that
|
||||
dangerously-set HTML / unescaped server strings are not introduced.
|
||||
|
||||
## 5. Performance and Big-O review
|
||||
|
||||
- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server
|
||||
collections, re-filtering/sorting on every render, unbounded list rendering
|
||||
without virtualization.
|
||||
- **MUST** confirm TanStack Query keys are stable and that mutations invalidate
|
||||
the correct keys (no stale cache, no redundant refetch storms).
|
||||
- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and
|
||||
unstable identities passed to memoized children.
|
||||
|
||||
## 6. No style-only comments
|
||||
|
||||
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
|
||||
enforces (formatting, naming nits the linter catches). Style is settled by the
|
||||
gates; review is for behavior, correctness, security, and architecture.
|
||||
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
|
||||
evidence-based convention in these docs, and offer a concrete fix or a
|
||||
targeted question. Use GitHub suggestion blocks when safe.
|
||||
|
||||
## 7. Review close-out
|
||||
|
||||
A review is complete when it records, briefly:
|
||||
|
||||
1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or
|
||||
"no findings".
|
||||
2. Open questions and their owner.
|
||||
3. Which of the above checks were run, and any that were skipped (and why).
|
||||
4. Residual risk, if approving.
|
||||
|
||||
Do not write a monolithic review body or a validation transcript into the PR
|
||||
surface; keep comments inline and high-signal.
|
||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
41
config/api-url-contract.test.ts
Normal file
41
config/api-url-contract.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
21
config/api-url-contract.ts
Normal file
21
config/api-url-contract.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
33
config/pwa-manifest.test.ts
Normal file
33
config/pwa-manifest.test.ts
Normal 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
102
docs/ARCHITECTURE_PLAN.md
Normal 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
137
docs/DESIGN_SYSTEM.md
Normal 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.
|
||||
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal 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
|
||||
160
docs/FEATURE_PRIORITIZATION.md
Normal file
160
docs/FEATURE_PRIORITIZATION.md
Normal 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`
|
||||
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
115
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Frontend maintainability conventions
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||
state. Use a ternary only when both branches render meaningful alternatives.
|
||||
|
||||
```tsx
|
||||
{
|
||||
error && <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
<Text variant="description" when={Boolean(description)}>
|
||||
{description}
|
||||
</Text>;
|
||||
```
|
||||
|
||||
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||
distinct from real either-or UI decisions.
|
||||
|
||||
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
|
||||
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
|
||||
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
|
||||
TypeScript checker for the type of the left operand and reports unless every union constituent is
|
||||
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
|
||||
fails closed: if type services are unavailable in a governed TSX file it reports rather than
|
||||
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
|
||||
same constraint at that component boundary.
|
||||
|
||||
Approved guard forms (choose by semantics, not as a blind codemod):
|
||||
|
||||
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
|
||||
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
|
||||
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
|
||||
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
|
||||
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
|
||||
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
|
||||
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
|
||||
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
|
||||
narrowing.
|
||||
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
|
||||
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
|
||||
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
|
||||
|
||||
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
|
||||
that needs a non-boolean operand must be rewritten into one of the approved forms above.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||
code, and asynchronous feedback. It owns:
|
||||
|
||||
- semantic HTML for each visual variant;
|
||||
- the display, body, and monospace font families;
|
||||
- default, muted, error, success, and warning tones;
|
||||
- accessible `alert` and `status` live regions for error and feedback text;
|
||||
- one-sided conditional text through `when`.
|
||||
|
||||
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
|
||||
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
|
||||
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
|
||||
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
|
||||
`aria-live` directly.
|
||||
|
||||
Live-region behavior:
|
||||
|
||||
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
|
||||
the polite region exists before its content changes and is announced reliably. Mounting an
|
||||
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
|
||||
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
|
||||
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
|
||||
|
||||
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
|
||||
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
|
||||
|
||||
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
|
||||
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
|
||||
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
|
||||
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
|
||||
static surface only. It cannot resolve dynamic or composed class values
|
||||
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
|
||||
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
Use the libraries already established in the application:
|
||||
|
||||
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||
- Zod owns form validation and inferred form value types.
|
||||
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||
invalidation, and retries where safe.
|
||||
|
||||
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||
benchmarks, a codemod plan, and an approved deprecation path.
|
||||
|
||||
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||
retry state. Do not place upload orchestration in a route-sized page component.
|
||||
|
||||
## Page state
|
||||
|
||||
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||
|
||||
- query loading, error, and empty states stay adjacent to the query result;
|
||||
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||
- route pages coordinate sections and navigation;
|
||||
- reusable sections own their interaction details;
|
||||
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||
|
||||
## Enforcement and rollout
|
||||
|
||||
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||
544
docs/UI_DOCUMENTATION.md
Normal file
544
docs/UI_DOCUMENTATION.md
Normal 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_
|
||||
148
docs/work-orders/board-search-api.md
Normal file
148
docs/work-orders/board-search-api.md
Normal 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.
|
||||
58
docs/work-orders/pending-be-contract.md
Normal file
58
docs/work-orders/pending-be-contract.md
Normal 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
104
e2e/auth/login.spec.ts
Normal 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
567
e2e/vendors/vendor-operations.spec.ts
vendored
Normal 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
552
e2e/vendors/vendors.spec.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
91
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
91
e2e/work-orders/vendor-inline-editor.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
|
||||
* boolean.
|
||||
*
|
||||
* `{value && <Element />}` renders its left operand when it is falsy and
|
||||
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
|
||||
* must be `boolean` (or boolean literals) across the whole union. The rule
|
||||
* asks the TypeScript checker for the type of the left operand and reports
|
||||
* unless every union constituent is boolean-like.
|
||||
*
|
||||
* Type-aware by design: a selector that cannot see operand types would give
|
||||
* false confidence rather than enforcement. If type services are unexpectedly
|
||||
* unavailable in a governed TSX file, the rule fails closed (reports) instead
|
||||
* of silently claiming the expression is safe.
|
||||
*/
|
||||
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
|
||||
|
||||
function isBooleanLikeType(type) {
|
||||
return (type.flags & booleanLikeFlags) !== 0;
|
||||
}
|
||||
|
||||
function isEntirelyBoolean(type) {
|
||||
if (type.isUnion()) {
|
||||
return type.types.every((constituent) => isBooleanLikeType(constituent));
|
||||
}
|
||||
return isBooleanLikeType(type);
|
||||
}
|
||||
|
||||
const transparentExpressionTypes = new Set([
|
||||
"ChainExpression",
|
||||
"ParenthesizedExpression",
|
||||
"TSAsExpression",
|
||||
"TSNonNullExpression",
|
||||
"TSTypeAssertion",
|
||||
]);
|
||||
|
||||
function isInRenderedPosition(node) {
|
||||
let current = node;
|
||||
while (current.parent) {
|
||||
const parent = current.parent;
|
||||
if (parent.type === "JSXExpressionContainer") {
|
||||
return parent.parent?.type !== "JSXAttribute";
|
||||
}
|
||||
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the left operand of `&&` in JSX to be entirely boolean",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonBooleanLeft:
|
||||
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
|
||||
typeServicesUnavailable:
|
||||
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const services = context.sourceCode?.parserServices ?? context.parserServices;
|
||||
|
||||
return {
|
||||
LogicalExpression(node) {
|
||||
if (node.operator !== "&&") {
|
||||
return;
|
||||
}
|
||||
if (!isInRenderedPosition(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!services || services.program === null) {
|
||||
context.report({ node, messageId: "typeServicesUnavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
const leftType = services.getTypeAtLocation(node.left);
|
||||
if (!isEntirelyBoolean(leftType)) {
|
||||
context.report({ node: node.left, messageId: "nonBooleanLeft" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
55
eslint-rules/no-vp-error-outside-text.js
Normal file
55
eslint-rules/no-vp-error-outside-text.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
function containsVpErrorToken(node) {
|
||||
if (!node || typeof node !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type === "Literal" && typeof node.value === "string") {
|
||||
return node.value.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
if (node.type === "TemplateElement") {
|
||||
return node.value.raw.split(/\s+/u).includes("vp-error");
|
||||
}
|
||||
|
||||
return Object.entries(node).some(([key, value]) => {
|
||||
if (key === "parent") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsVpErrorToken);
|
||||
}
|
||||
return containsVpErrorToken(value);
|
||||
});
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require the Text component for legacy vp-error styling",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useText:
|
||||
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementName = node.parent?.name;
|
||||
if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({ node, messageId: "useText" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
103
eslint.config.js
Normal file
103
eslint.config.js
Normal file
File diff suppressed because one or more lines are too long
28
index.html
Normal file
28
index.html
Normal 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
161
infra/cdk/README.md
Normal 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
40
infra/cdk/bin/app.ts
Normal 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
19
infra/cdk/cdk.json
Normal 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"
|
||||
}
|
||||
}
|
||||
238
infra/cdk/lib/frontend-stack.ts
Normal file
238
infra/cdk/lib/frontend-stack.ts
Normal 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
514
infra/cdk/package-lock.json
generated
Normal 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
29
infra/cdk/package.json
Normal 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
25
infra/cdk/tsconfig.json
Normal 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
6880
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
105
package.json
Normal file
105
package.json
Normal 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
31
playwright.config.ts
Normal 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
BIN
public/BrandingImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
108
public/seahaven-logo.svg
Normal file
108
public/seahaven-logo.svg
Normal 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
55
scripts/deploy-web.sh
Executable 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."
|
||||
17
scripts/governance-baseline.json
Normal file
17
scripts/governance-baseline.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
293
scripts/governance-check.mjs
Normal file
293
scripts/governance-check.mjs
Normal 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();
|
||||
224
scripts/vendor-e2e-mock-server.mjs
Normal file
224
scripts/vendor-e2e-mock-server.mjs
Normal 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
55
src/api/api-error.ts
Normal 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;
|
||||
}
|
||||
179
src/api/api-paths.ts
Normal file
179
src/api/api-paths.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
|
||||
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
|
||||
`workorders/${workOrderId}/uplifts/${upliftId}/revoke`,
|
||||
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
118
src/api/api.ts
Normal 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 };
|
||||
62
src/api/handle-api-response.ts
Normal file
62
src/api/handle-api-response.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
src/api/paginated-response-fields.ts
Normal file
36
src/api/paginated-response-fields.ts
Normal 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
5
src/app-root.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Routes } from "@/app-routes";
|
||||
|
||||
export function AppRoot() {
|
||||
return <Routes />;
|
||||
}
|
||||
1
src/app-routes.tsx
Normal file
1
src/app-routes.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Routes } from "@/routing";
|
||||
27
src/app/(auth)/_components/auth-card-header.tsx
Normal file
27
src/app/(auth)/_components/auth-card-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/app/(auth)/_components/login-form.tsx
Normal file
96
src/app/(auth)/_components/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/_layout.tsx
Normal file
11
src/app/(auth)/_layout.tsx
Normal 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
50
src/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/(protected)/_layout.tsx
Normal file
36
src/app/(protected)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/(protected)/accounts/_components/account-form.tsx
Normal file
194
src/app/(protected)/accounts/_components/account-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/account-form";
|
||||
242
src/app/(protected)/accounts/index.tsx
Normal file
242
src/app/(protected)/accounts/index.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/accounts/new.tsx
Normal file
1
src/app/(protected)/accounts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/account-form";
|
||||
218
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
218
src/app/(protected)/assets/_components/asset-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/asset-form";
|
||||
244
src/app/(protected)/assets/index.tsx
Normal file
244
src/app/(protected)/assets/index.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/new.tsx
Normal file
1
src/app/(protected)/assets/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/asset-form";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
289
src/app/(protected)/calendar/_components/event-form.tsx
Normal file
289
src/app/(protected)/calendar/_components/event-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(protected)/calendar/_layout.tsx
Normal file
13
src/app/(protected)/calendar/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/calendar-unavailable";
|
||||
1
src/app/(protected)/calendar/index.tsx
Normal file
1
src/app/(protected)/calendar/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/calendar-unavailable";
|
||||
1
src/app/(protected)/calendar/new.tsx
Normal file
1
src/app/(protected)/calendar/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/calendar-unavailable";
|
||||
265
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
265
src/app/(protected)/contacts/_components/contact-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/contact-form";
|
||||
221
src/app/(protected)/contacts/index.tsx
Normal file
221
src/app/(protected)/contacts/index.tsx
Normal 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 "{deleteTarget?.name}"?
|
||||
</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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/contacts/new.tsx
Normal file
1
src/app/(protected)/contacts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/contact-form";
|
||||
240
src/app/(protected)/dashboard.tsx
Normal file
240
src/app/(protected)/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
378
src/app/(protected)/employees/_components/employee-form.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/employee-form";
|
||||
253
src/app/(protected)/employees/index.tsx
Normal file
253
src/app/(protected)/employees/index.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/new.tsx
Normal file
1
src/app/(protected)/employees/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/employee-form";
|
||||
245
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal file
245
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/follow-up-form";
|
||||
255
src/app/(protected)/followups/index.tsx
Normal file
255
src/app/(protected)/followups/index.tsx
Normal 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
Loading…
Add table
Reference in a new issue