Merge remote-tracking branch 'origin/dev' into feat/ab/playground

This commit is contained in:
Alexandre Brandizzi 2026-07-07 15:59:08 -03:00
commit 05d83a2edc
348 changed files with 38141 additions and 0 deletions

View file

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

1
.env Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=

5
.env.development Normal file
View file

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

12
.env.example Normal file
View file

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

4
.env.production Normal file
View file

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

3
.github/CODEOWNERS vendored Normal file
View file

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

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

@ -0,0 +1,16 @@
name: CI
on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
with:
node-version: "24"

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

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

49
.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# 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/*.js
infra/cdk/lib/*.js
# local Claude Code overrides
.claude/settings.local.json

1
.husky/commit-msg Normal file
View file

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

1
.husky/pre-commit Normal file
View file

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

13
.prettierignore Normal file
View file

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

15
.prettierrc Normal file
View file

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

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# SeaHaven Admin
Vite + React SPA for SeaHaven facility management (work orders, vendor portal, uplifts, and related admin features).
## Requirements
- Node.js 20+
- npm 10+
## Setup
```bash
npm ci
cp .env.example .env
```
### Environment variables
| Variable | Description | Default |
| ----------------- | ----------------------------------------------- | ----------------------- |
| `VITE_API_URL` | Ky API prefix (baked into the production build) | `/api` |
| `VITE_API_TARGET` | Dev proxy target for `/api` (Vite only) | `http://localhost:5141` |
`VITE_API_URL` is the full API base prefix. Route paths in `API_PATHS` do **not** include `/api` — only this variable supplies it.
- **Development:** use `VITE_API_URL=/api`. The Vite dev server proxies `/api` to `VITE_API_TARGET`.
- **Production:** use an absolute URL that **ends with `/api`** (e.g. `https://api.example.com/api`). Absolute URLs without `/api` will produce broken API requests.
See [`.env.example`](.env.example) and [`.env.production`](.env.production) for reference values.
## Scripts
| Command | Description |
| ---------------------- | ------------------------------------------ |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check and production build to `dist/` |
| `npm run preview` | Preview production build locally |
| `npm test` | Run Vitest unit tests |
| `npm run test:watch` | Run Vitest in watch mode |
| `npm run lint` | ESLint |
| `npm run lint:fix` | ESLint with auto-fix |
| `npm run format` | Prettier write |
| `npm run format:check` | Prettier check (used in CI) |
## Architecture
New code lives under `src/domain/`, `src/app/`, and `src/api/` following the IrisLoan conventions documented in `docs/ARCHITECTURE_PLAN.md`.
## CI
GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push and pull requests:
1. `npm run format:check`
2. `npm run lint`
3. `npm run build`
4. `npm test`
Local pre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files.
## Deployment (CI/CD)
The app is hosted on **AWS S3 + CloudFront**, provisioned by an **AWS CDK** app
local to this repo ([`infra/cdk/`](infra/cdk/README.md)). Deployment runs
through the org's reusable GitHub Actions workflow via **OIDC** (no stored AWS
keys):
- Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable
`cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh`
(builds the SPA, syncs `dist/` to S3, invalidates CloudFront).
- Served on the custom domain `dev.seahaven.com`; the SPA calls the backend
directly over HTTPS at `VITE_API_URL` (`https://api.dev.seahaven.com/api`,
cross-origin — the backend allows CORS). `VITE_API_URL` is baked into the
build, so it is per-environment.
- First-time provisioning (OIDC provider, CDK bootstrap, first local deploy, the
`AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see
[`infra/cdk/README.md`](infra/cdk/README.md).
## Playground (dev/staging only)
A hidden test panel lives at **`/playground`** — not linked from any nav; reach
it by typing the URL. It's a data/scenario control panel for testers that only
calls the app's **normal, authenticated API routes** — no privileged or
structure-bypassing endpoints. It offers a raw request console (any authenticated
endpoint), one-click lifecycle recipes (create work order, change status,
dispatch, complete/cancel follow-ups, manage users/roles…), and bulk seeding.
Gated by `env.isPlaygroundEnabled` (see `src/lib/env.ts`): available on local
dev + deployed **dev/staging**, and **404 in production** (fail-safe). Because
deployed dev/staging/prod are all production builds, the environment is resolved
from `VITE_APP_ENV` if set, else derived from the API hostname (`api.dev.` /
`api.staging.`). It requires a logged-in session and drives the real backend
API as the current user. Code lives under `src/app/playground/`.
## Development proxy
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).

1
commitlint.config.js Normal file
View file

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

View file

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

View file

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

102
docs/ARCHITECTURE_PLAN.md Normal file
View file

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

137
docs/DESIGN_SYSTEM.md Normal file
View file

@ -0,0 +1,137 @@
# SeaHaven 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 Seahaven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
### Prefer semantic Tailwind in layout shells
```tsx
<div className="bg-background text-foreground border-border" />
<div className="text-muted-foreground" />
<div className="bg-primary text-primary-foreground" />
```
### Compose classes with `cn()`
```tsx
import { cn } from "@/lib/utils";
<div className={cn("flex gap-2", isActive && "bg-sidebar-accent")} />;
```
### Shared UI wrappers
| Component | Path | Pattern |
| ------------ | ------------------------------- | ---------------------------- |
| `Button` | `components/ui/button.tsx` | MUI Button + variant presets |
| `Badge` | `components/ui/badge.tsx` | MUI Chip |
| `PageHeader` | `components/ui/page-header.tsx` | PATTERN_001 |
| `EmptyState` | `components/ui/empty-state.tsx` | PATTERN_003 |
| `FormField` | `components/ui/form-field.tsx` | PATTERN_004 |
### Domain badges
| Component | Path |
| -------------------------------------- | ------------------------------------ |
| `TypeBadge` | `components/domain/type-badge.tsx` |
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
| `DocBadge` | `components/domain/doc-badge.tsx` |
## Legacy → Seahaven mapping
| Legacy | Seahaven | Consumption |
| ------------------------- | ------------------------------ | -------------------------------- |
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
| `#f4f6f7` / `--bg` | `--color-bg-page` | Body, page shell |
| `#d9dde0` / `--border` | `--border` | Dividers, inputs |
| `#fbfbfb` sidebar | `--color-sidebar-bg` | App sidebar |
| Segoe UI 13px | DM Sans 14px | Body typography |
| Bootstrap calendar colors | `--primary`, `--success`, etc. | `calendar-utils.ts` |
| `#1a3a5c` login header | `--color-header-bg-start` | Login branding strip |
## Dark mode
`@custom-variant dark (&:is(.dark *))` is configured in `globals.css`. Full dark token set is a future enhancement; light mode is the current default.

View file

@ -0,0 +1,88 @@
# Design System Migration Log
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens. Strategy: **MUI-only**.
## Phase 1 — Foundation
| File | Action | Reason |
| --------------------------- | ---------- | --------------------------------------------------------------------- |
| `src/styles/theme.css` | Created | SoT: 131+ Seahaven 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 Seahaven 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 → Seahaven 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 Seahaven active/hover states
- Header: flat blue gradient → Seahaven multi-stop gradient
## Not migrated (intentional)
- CRUD list pages using default MUI theming — inherit via `mui-theme.ts` without JSX changes
- `src/app/v/_components/signature-pad.tsx` — vendor portal isolated; minor `#111` stroke remains

View file

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

544
docs/UI_DOCUMENTATION.md Normal file
View file

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

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

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

64
eslint.config.js Normal file

File diff suppressed because one or more lines are too long

28
index.html Normal file
View file

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

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

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

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

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

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

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

View file

@ -0,0 +1,238 @@
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
export interface FrontendStackProps extends StackProps {
/** Environment label, e.g. "dev". Used in names/tags. */
readonly envName: string;
/** GitHub repo in owner/name form, for OIDC trust scoping. */
readonly githubRepo: string;
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain.
*/
readonly domainNames: string[];
/**
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
* use a certificate from another account, so for Option B the cert must live
* in whichever account this stack deploys to.
*/
readonly certificateArn: string;
/**
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
* record in. Empty = don't manage DNS (add the record manually). When set,
* hostedZoneName must also be provided.
*/
readonly hostedZoneId: string;
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
readonly hostedZoneName: string;
}
/**
* Static SPA hosting for the SeaHaven 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",
});
}
}

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

@ -0,0 +1,410 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@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=="
},
"@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=="
},
"@aws-cdk/cloud-assembly-schema": {
"version": "54.5.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz",
"integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==",
"requires": {
"jsonschema": "^1.5.0",
"semver": "^7.8.4"
},
"dependencies": {
"jsonschema": {
"version": "1.5.0",
"bundled": true
},
"semver": {
"version": "7.8.4",
"bundled": true
}
}
},
"@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,
"requires": {
"@jridgewell/trace-mapping": "0.3.9"
}
},
"@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
},
"@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
},
"@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,
"requires": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@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
},
"@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
},
"@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
},
"@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
},
"@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"dev": true,
"requires": {
"undici-types": "~6.21.0"
}
},
"acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true
},
"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,
"requires": {
"acorn": "^8.11.0"
}
},
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"aws-cdk": {
"version": "2.1128.1",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz",
"integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==",
"dev": true
},
"aws-cdk-lib": {
"version": "2.260.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz",
"integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==",
"requires": {
"@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",
"table": "^6.9.0",
"yaml": "1.10.3"
},
"dependencies": {
"@aws-cdk/cloud-assembly-api": {
"version": "2.2.5",
"bundled": true,
"requires": {
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
}
},
"@balena/dockerignore": {
"version": "1.0.2",
"bundled": true
},
"ajv": {
"version": "8.20.0",
"bundled": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}
},
"ansi-regex": {
"version": "5.0.1",
"bundled": true
},
"ansi-styles": {
"version": "4.3.0",
"bundled": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"astral-regex": {
"version": "2.0.0",
"bundled": true
},
"balanced-match": {
"version": "4.0.4",
"bundled": true
},
"brace-expansion": {
"version": "5.0.6",
"bundled": true,
"requires": {
"balanced-match": "^4.0.2"
}
},
"case": {
"version": "1.6.3",
"bundled": true
},
"color-convert": {
"version": "2.0.1",
"bundled": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"bundled": true
},
"emoji-regex": {
"version": "8.0.0",
"bundled": true
},
"fast-deep-equal": {
"version": "3.1.3",
"bundled": true
},
"fast-uri": {
"version": "3.1.2",
"bundled": true
},
"fs-extra": {
"version": "11.3.5",
"bundled": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"graceful-fs": {
"version": "4.2.11",
"bundled": true
},
"ignore": {
"version": "5.3.2",
"bundled": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"bundled": true
},
"json-schema-traverse": {
"version": "1.0.0",
"bundled": true
},
"jsonfile": {
"version": "6.2.1",
"bundled": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"jsonschema": {
"version": "1.5.0",
"bundled": true
},
"lodash.truncate": {
"version": "4.4.2",
"bundled": true
},
"mime-db": {
"version": "1.52.0",
"bundled": true
},
"mime-types": {
"version": "2.1.35",
"bundled": true,
"requires": {
"mime-db": "1.52.0"
}
},
"minimatch": {
"version": "10.2.5",
"bundled": true,
"requires": {
"brace-expansion": "^5.0.5"
}
},
"punycode": {
"version": "2.3.1",
"bundled": true
},
"require-from-string": {
"version": "2.0.2",
"bundled": true
},
"semver": {
"version": "7.8.1",
"bundled": true
},
"slice-ansi": {
"version": "4.0.0",
"bundled": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
}
},
"string-width": {
"version": "4.2.3",
"bundled": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"bundled": true,
"requires": {
"ansi-regex": "^5.0.1"
}
},
"table": {
"version": "6.9.0",
"bundled": true,
"requires": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
}
},
"universalify": {
"version": "2.0.1",
"bundled": true
},
"yaml": {
"version": "1.10.3",
"bundled": true
}
}
},
"constructs": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ=="
},
"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
},
"diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true
},
"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
},
"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,
"requires": {
"@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"
}
},
"typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true
},
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"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
},
"yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
}
}
}

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

@ -0,0 +1,25 @@
{
"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 SeaHaven SHOC frontend.",
"bin": {
"app": "bin/app.ts"
},
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"diff": "cdk diff",
"deploy": "cdk deploy"
},
"devDependencies": {
"@types/node": "^22.15.30",
"aws-cdk": "^2.1021.0",
"ts-node": "^10.9.2",
"typescript": "~5.8.3"
},
"dependencies": {
"aws-cdk-lib": "^2.220.0",
"constructs": "^10.4.2"
}
}

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

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

12541
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

104
package.json Normal file
View file

@ -0,0 +1,104 @@
{
"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 .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"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.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"clsx": "^2.1.1",
"ky": "^1.7.5",
"lucide-react": "^1.17.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.78.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.6.0",
"web-vitals": "^2.1.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.15.30",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.4.0",
"prettier": "^3.8.4",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"typescript-eslint": "^8.61.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

26
playwright.config.ts Normal file
View file

@ -0,0 +1,26 @@
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: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

BIN
public/BrandingImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 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": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View file

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

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

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

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

@ -0,0 +1,45 @@
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
if (status === 401) {
return "You are not authorized to access this page.";
}
if (status === 403) {
return "You do not have permission to perform this action.";
}
if (status === 404) {
return "The requested resource was not found.";
}
if (status === 500) {
return "A server error occurred. Please try again later.";
}
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
const message = record.message ?? record.Message ?? record.error;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return "An error occurred";
}
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
}

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

@ -0,0 +1,154 @@
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",
},
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",
},
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: "Vendor/Dropdown",
},
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;

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

@ -0,0 +1,113 @@
import ky, { type HTTPError, 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 { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
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);
}
export const api: KyInstance = ky.create({
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
(request) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
},
],
beforeError: [
async (error: HTTPError) => {
const { response } = error;
if (!response) {
error.message = "No response from server. Please check your connection.";
return error;
}
const apiError = await createApiErrorFromResponse(response);
error.message = apiError.message;
return error;
},
],
afterResponse: [
async (request, _options, response) => {
if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}
clearSessionAndRedirectToLogin();
return response;
},
],
},
});
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
return api.get(url, options).json<T>();
}
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.post(url, { ...options, json: body }).json<T>();
}
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.put(url, { ...options, json: body }).json<T>();
}
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
return api.delete(url, options).json<T>();
}
export async function apiPostNoContent(
url: string,
body?: unknown,
options?: Options,
): Promise<void> {
const response = await api.post(url, { ...options, json: body });
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
}
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
const response = await api.delete(url, options);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
}
export { mapHttpStatusToMessage };

View file

@ -0,0 +1,73 @@
export interface PaginatedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function handleApiResponse<T>(data: unknown): T {
if (Array.isArray(data)) {
return data as T;
}
if (isRecord(data)) {
const nested = data.data ?? data.Data ?? data.value;
if (nested !== undefined) {
return nested as T;
}
}
return data as T;
}
export function handlePaginatedResponse<T>(
data: unknown,
params?: { page?: number; pageSize?: number },
): PaginatedResult<T> {
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? 12;
if (Array.isArray(data)) {
return {
items: data as T[],
totalCount: data.length,
page,
pageSize,
};
}
if (isRecord(data)) {
const nested = data.data ?? data.Data ?? data.value ?? data;
const items = Array.isArray(nested) ? (nested as T[]) : [];
const totalCount =
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
(typeof data.TotalCount === "number" ? data.TotalCount : undefined) ??
(typeof data.Count === "number" ? data.Count : undefined) ??
items.length;
return {
items,
totalCount,
page:
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
page,
pageSize:
(typeof data.pageSize === "number" ? data.pageSize : undefined) ??
(typeof data.PageSize === "number" ? data.PageSize : undefined) ??
pageSize,
};
}
return {
items: [],
totalCount: 0,
page,
pageSize,
};
}

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

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

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

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

View file

@ -0,0 +1,21 @@
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
title: string;
subtitle?: string;
};
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
{subtitle}
</p>
)}
</div>
);
}

View file

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

View file

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

View file

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

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

@ -0,0 +1,45 @@
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 { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
export default function LoginPage() {
const { login, isLoggingIn, loginError } = useAuthContext();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
return (
<AuthPageShell>
<BrandLockup className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm
register={register}
handleSubmit={handleSubmit}
watch={watch}
errors={errors}
onSubmit={login}
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
Having trouble? Contact your administrator.
</p>
</AuthPageShell>
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,221 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useDeleteAccount } from "@/domain/accounts/use-cases/use-delete-account";
import { useDebounce } from "@/hooks/use-debounce";
export default function AccountsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<AccountListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteAccount = useDeleteAccount();
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
}),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useAccountsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
deleteAccount.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Accounts
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search accounts..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/accounts/new")}>
+ New Account
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Number</TableCell>
<TableCell className="font-semibold">Industry</TableCell>
<TableCell className="font-semibold">Website</TableCell>
<TableCell className="font-semibold">Primary Contact</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Owner</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No accounts found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/accounts/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.number || "—"}</TableCell>
<TableCell>{row.industry || "—"}</TableCell>
<TableCell>{row.website || "—"}</TableCell>
<TableCell>{row.primaryContact || "—"}</TableCell>
<TableCell>{row.phoneNumber || "—"}</TableCell>
<TableCell>{row.owner || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit account"
onClick={() => navigate(`/accounts/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete account"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Account</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAccount.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteAccount.isPending}
>
{deleteAccount.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,223 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { AssetListItem } from "@/domain/assets/types/asset";
import { useAssetsList } from "@/domain/assets/use-cases/use-assets-list";
import { useDeleteAsset } from "@/domain/assets/use-cases/use-delete-asset";
import { useDebounce } from "@/hooks/use-debounce";
export default function AssetsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<AssetListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteAsset = useDeleteAsset();
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
}),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useAssetsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteAsset.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Assets
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search assets..."
className="min-w-sidebar"
/>
<Button variant="contained" onClick={() => navigate("/assets/new")}>
+ New Asset
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Asset Code</TableCell>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Description</TableCell>
<TableCell className="font-semibold">Account</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography color="text.secondary">No assets found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.assetCode || "—"}</TableCell>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/assets/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.description || "—"}</TableCell>
<TableCell>{row.accountName || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>
<Chip
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
size="small"
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit asset"
onClick={() => navigate(`/assets/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete asset"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Asset</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAsset.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,267 @@
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-dom";
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<LocationFormValues>({
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 (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
<Button variant="text" onClick={() => navigate("/locations")}>
Back to list
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => 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) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<Controller
name="state"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.state)}>
<InputLabel id="state-label">State</InputLabel>
<Select {...field} labelId="state-label" label="State" value={field.value}>
{LOCATION_STATE_OPTIONS.map((state) => (
<MenuItem key={state} value={state}>
{state}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("phone")}
label="Phone"
required
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
<TextField
{...register("contact")}
label="Contact Person"
required
error={Boolean(errors.contact)}
helperText={errors.contact?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={2}>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

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

View file

@ -0,0 +1,221 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
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<LocationListItem | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Locations
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search locations..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/locations/new")}>
+ New Location
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No locations found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>{row.state || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.contact || "—"}</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit location"
onClick={() => navigate(`/locations/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteLocation.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteLocation.isPending}
>
{deleteLocation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

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

View file

@ -0,0 +1,210 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
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<PmScheduleFormValues>({
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 (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
{optionsError && (
<Alert severity="error">
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit((values) => savePmSchedule.mutate(values))}>
<Stack spacing={3}>
<TextField
label="Name"
required
fullWidth
error={Boolean(errors.name)}
helperText={errors.name?.message}
{...register("name")}
/>
<Controller
name="frequency"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Frequency</InputLabel>
<Select label="Frequency" value={field.value} onChange={field.onChange}>
{["Daily", "Weekly", "Monthly", "Quarterly", "Semi-Annual", "Annual"].map(
(value) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
),
)}
</Select>
</FormControl>
)}
/>
<TextField
label="Start Date"
type="date"
required
fullWidth
InputLabelProps={{ shrink: true }}
error={Boolean(errors.startDate)}
helperText={errors.startDate?.message}
{...register("startDate")}
/>
<Controller
name="assetId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Asset</InputLabel>
<Select
label="Asset"
value={field.value ?? ""}
onChange={(event) => {
const value = String(event.target.value);
field.onChange(value === "" ? null : Number(value));
}}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{assets.map((asset) => (
<MenuItem key={String(asset.id)} value={asset.id}>
{asset.name || asset.assetCode}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="locationId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select
label="Location"
value={field.value ?? ""}
onChange={(event) => {
const value = String(event.target.value);
field.onChange(value === "" ? null : Number(value));
}}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{locations.map((location) => (
<MenuItem key={String(location.id)} value={location.id}>
{location.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={field.onChange} />}
label="Active"
/>
)}
/>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={savePmSchedule.isPending}>
{savePmSchedule.isPending ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,33 @@
import { Alert, Box, Button, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
export function PmScheduleEditUnavailablePanel() {
return (
<Box className="mx-auto max-w-3xl">
<Alert severity="warning" className="mb-4">
PM Schedule editing is unavailable — the production API does not expose{" "}
<code className="text-sm">PMSchedule/GetById</code>.
</Alert>
<Typography variant="body2" color="text.secondary">
List and create remain available via <code className="text-sm">GetList</code> and{" "}
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
GetById.
</Typography>
</Box>
);
}
export default function PmScheduleEditUnavailablePage() {
const navigate = useNavigate();
return (
<Box className="p-4">
<PmScheduleEditUnavailablePanel />
<Box className="mt-4 flex justify-end">
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
Back to PM Schedules
</Button>
</Box>
</Box>
);
}

View file

@ -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;

View file

@ -0,0 +1,246 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
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<PmScheduleListItem | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
PM Schedules
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search PM schedules..."
className="min-w-[220px]"
/>
{pmScheduleCapabilities.canCreate && (
<Button variant="contained" onClick={() => navigate("/pmschedules/new")}>
+ New PM Schedule
</Button>
)}
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load PM schedules"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Frequency</TableCell>
<TableCell className="font-semibold">Start Date</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Asset</TableCell>
<TableCell className="font-semibold">Location</TableCell>
{showActionsColumn && (
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Typography color="text.secondary">No PM schedules found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
{pmScheduleCapabilities.canEdit ? (
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
) : (
<Typography variant="body2" className="font-semibold">
{row.name || "—"}
</Typography>
)}
</TableCell>
<TableCell>{row.frequency || "—"}</TableCell>
<TableCell>{row.startDate || "—"}</TableCell>
<TableCell>
<Chip
label={row.isActive ? "Active" : "Inactive"}
size="small"
color={row.isActive ? "success" : "default"}
variant="outlined"
/>
</TableCell>
<TableCell>{row.asset || "—"}</TableCell>
<TableCell>{row.location || "—"}</TableCell>
{showActionsColumn && (
<TableCell align="right">
{pmScheduleCapabilities.canEdit && (
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit PM schedule"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{pmScheduleCapabilities.canDelete && (
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete PM schedule"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
{pmScheduleCapabilities.canDelete && (
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete PM Schedule</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot
be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deletePmSchedule.isPending}
>
{deletePmSchedule.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
)}
</Box>
);
}

View file

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

View file

@ -0,0 +1,297 @@
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<DropdownOptionCategory>("Trade");
const [newValue, setNewValue] = useState("");
const [newParent, setNewParent] = useState("");
const [editingId, setEditingId] = useState<string | number | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<SettingsNav />
<Stack spacing={0.5}>
<Typography variant="h5" component="h1">
Dropdown Options
</Typography>
<Typography variant="body2" color="text.secondary">
Manage configurable dropdown values for work orders
</Typography>
</Stack>
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
<Button
key={category.key}
onClick={() => handleCategoryChange(category.key)}
className={`justify-start rounded-none border-b border-border px-4 py-3 text-left normal-case ${
activeCategory === category.key
? "border-r-2 border-r-primary bg-background font-semibold text-primary"
: "font-medium text-muted-foreground hover:bg-muted/50"
}`}
>
{category.label}
</Button>
))}
</Stack>
<Box className="flex flex-1 flex-col gap-4 p-4">
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
{parentCategory && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select
labelId="parent-trade-label"
label="Parent Trade"
value={newParent}
onChange={(event) => setNewParent(event.target.value)}
>
<MenuItem value="">Parent: All</MenuItem>
{parentOptions.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
size="small"
value={newValue}
onChange={(event) => setNewValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAdd();
}
}}
placeholder={addPlaceholder}
className="flex-1"
/>
<Button
variant="contained"
onClick={handleAdd}
disabled={!newValue.trim() || isMutating}
className="shrink-0"
>
Add
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"}
</Alert>
)}
{isLoading ? (
<Stack alignItems="center" className="py-10">
<CircularProgress size={28} />
</Stack>
) : options.length === 0 ? (
<Typography align="center" color="text.secondary" className="py-10">
No options configured yet
</Typography>
) : (
<Stack spacing={1}>
{options.map((item) => (
<Paper
key={String(item.id)}
variant="outlined"
className="flex items-center justify-between gap-3 px-3 py-2"
>
{editingId === item.id ? (
<TextField
size="small"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleEditSave(item);
}
if (event.key === "Escape") {
setEditingId(null);
}
}}
autoFocus
className="flex-1"
/>
) : (
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
<Typography variant="body2" className="truncate">
{item.value}
</Typography>
{item.parentValue && (
<Chip size="small" label={item.parentValue} variant="outlined" />
)}
</Stack>
)}
<Stack direction="row" spacing={1} className="shrink-0">
{editingId === item.id ? (
<>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handleEditSave(item)}
disabled={isMutating}
>
Save
</Button>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setEditingId(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
variant="outlined"
onClick={() => {
setEditingId(item.id);
setEditValue(item.value);
}}
>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleDelete(item.id)}
disabled={isMutating}
>
Delete
</Button>
</>
)}
</Stack>
</Paper>
))}
</Stack>
)}
</Box>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,274 @@
import { useState } from "react";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
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<string | number | "new" | null>(null);
const [form, setForm] = useState<TaskTemplateFormValues>(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 (
<Box className="p-4">
<SettingsNav />
<Stack spacing={1} className="mb-4">
<Typography variant="h5">Task List Templates</Typography>
<Typography variant="body2" color="text.secondary">
Create reusable checklists for work order dispatches
</Typography>
</Stack>
{error && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
</Alert>
)}
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
<Paper variant="outlined" className="p-3">
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
+ New Template
</Button>
{isLoading ? (
<Box className="flex justify-center p-4">
<CircularProgress size={24} />
</Box>
) : (
<List dense disablePadding>
{templates.map((template) => (
<ListItemButton
key={String(template.id)}
selected={selectedId === template.id}
onClick={() => handleSelect(template)}
>
<ListItemText
primary={template.name}
secondary={`${template.items.length} items`}
/>
</ListItemButton>
))}
</List>
)}
</Paper>
<Paper variant="outlined" className="p-4">
{!selectedId ? (
<Typography color="text.secondary" className="py-10 text-center">
Select a template or create a new one
</Typography>
) : (
<Stack spacing={3}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Template Name"
fullWidth
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g., HVAC Inspection"
/>
<TextField
label="Description"
fullWidth
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</Stack>
<Typography variant="subtitle2" className="font-semibold">
Checklist Items ({form.items.length})
</Typography>
<Stack spacing={1}>
{form.items.map((item, index) => (
<Box
key={`${item.sortOrder}-${index}`}
className="flex items-center gap-2 rounded border border-border p-2"
>
<Typography variant="body2" className="w-6 text-muted-foreground">
{index + 1}
</Typography>
<Typography variant="body2" className="flex-1">
{item.text}
</Typography>
<IconButton
size="small"
disabled={index === 0}
onClick={() => handleMoveItem(index, -1)}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
disabled={index === form.items.length - 1}
onClick={() => handleMoveItem(index, 1)}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleRemoveItem(index)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Box>
))}
</Stack>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddItem();
}
}}
placeholder="Add checklist item..."
/>
<Button variant="outlined" onClick={handleAddItem} disabled={!newItemText.trim()}>
Add
</Button>
</Stack>
<Stack direction="row" spacing={2} justifyContent="flex-end">
{!isNew && (
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? "Saving..." : isNew ? "Create Template" : "Save Changes"}
</Button>
</Stack>
</Stack>
)}
</Paper>
</Box>
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
<DialogTitle>Delete Template</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{form.name}&quot;?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
<Button color="error" variant="contained" onClick={handleDelete}>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,312 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select
label="Tier"
value={tier}
onChange={(e) => {
setTier(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Delta</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography color="text.secondary">
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>{formatMoney(row.delta)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{row.workOrderId && (
<Button
size="small"
variant="outlined"
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
>
View
</Button>
)}
{row.status === "Pending" && (
<>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "approve", item: row });
setNote("");
}}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "deny", item: row });
setNote("");
}}
>
Deny
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve"
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
: "Deny Uplift"}
</DialogTitle>
<DialogContent>
{upliftAction?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(upliftAction.item.currentNTE)} →{" "}
{formatMoney(upliftAction.item.requestedNTE)} (+
{formatMoney(upliftAction.item.delta)})
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,345 @@
import { useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
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/work-orders/types/work-order";
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 (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
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 (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
if (error || !dispatch) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Vendor PO not found"}
</Alert>
</Box>
);
}
const vendor = dispatch.vendor;
return (
<Box className="mx-auto max-w-5xl p-4">
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
<Button variant="text" onClick={() => navigate(-1)}>
← Back
</Button>
<Button variant="outlined" onClick={() => window.print()}>
Print
</Button>
</Stack>
<Stack spacing={1} className="mb-4">
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="body2" color="text.secondary">
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
<Typography variant="body2" color="text.secondary">
· {vendor.companyName}
</Typography>
)}
{dispatch.nteAmount != null && (
<Typography variant="body2" color="text.secondary">
· NTE {formatMoney(dispatch.nteAmount)}
</Typography>
)}
</Stack>
</Stack>
<Stack spacing={3}>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor
</Typography>
{vendor ? (
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="Company">{vendor.companyName}</InfoRow>
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
<InfoRow label="Email">{vendor.email}</InfoRow>
<InfoRow label="Phone">{vendor.phone}</InfoRow>
<InfoRow label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{dispatch.description && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
{dispatch.primaryLocation && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
<InfoRow label="Address">
{[
dispatch.primaryLocation.address,
dispatch.primaryLocation.city,
dispatch.primaryLocation.state,
dispatch.primaryLocation.zipCode,
]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
)}
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dispatch.workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button
variant="text"
size="small"
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{u.vendorReason && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>
)}
{u.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
onClick={() => {
setUpliftAction({ type: "approve", uplift: u });
setNote("");
}}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
onClick={() => {
setUpliftAction({ type: "deny", uplift: u });
setNote("");
}}
>
Deny
</Button>
</Stack>
)}
</Box>
))}
</Stack>
)}
</Paper>
</Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
required={upliftAction?.type === "deny"}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleUpliftConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,281 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
import { useDebounce } from "@/hooks/use-debounce";
import { toDateInputValue } from "@/lib/time-utils";
const STATUS_OPTIONS = [
"",
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
];
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 default function VendorPosListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [status, setStatus] = useState("");
const [vendorId, setVendorId] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const debouncedSearch = useDebounce(search, 300);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
status,
vendorId,
dateFrom,
dateTo,
}),
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
);
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 ?? [];
const clearFilters = () => {
setStatus("");
setVendorId("");
setDateFrom("");
setDateTo("");
setPage(0);
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Vendor POs
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<TextField
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search PO #, dispatch, vendor, WO..."
className="min-w-[280px]"
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} flexWrap="wrap">
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[180px]">
<InputLabel>Vendor</InputLabel>
<Select
label="Vendor"
value={vendorId}
onChange={(e) => {
setVendorId(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
{vendors.map((v) => (
<MenuItem key={String(v.id)} value={String(v.id)}>
{v.companyName}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="date"
label="From"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
size="small"
type="date"
label="To"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
/>
<Button variant="text" onClick={clearFilters}>
Clear filters
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">PO #</TableCell>
<TableCell className="font-semibold">Dispatch #</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">WO #</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">NTE</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Uplifts</TableCell>
<TableCell className="font-semibold">Dispatched</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography color="text.secondary">No vendor POs found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={String(row.id)}
hover
className="cursor-pointer"
onClick={() => navigate(`/vendor-pos/${row.id}`)}
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={(e) => {
e.stopPropagation();
navigate(`/vendor-pos/${row.id}`);
}}
>
{row.poNumber || "—"}
</Button>
</TableCell>
<TableCell>{row.dispatchNumber || "—"}</TableCell>
<TableCell>{row.vendorName || "—"}</TableCell>
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
<TableCell>
<Chip size="small" label={row.status || "—"} />
</TableCell>
<TableCell>
{row.pendingUpliftCount > 0 ? (
<Chip
size="small"
color="warning"
label={`${row.pendingUpliftCount} pending`}
/>
) : (
"—"
)}
</TableCell>
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
</Box>
);
}

View file

@ -0,0 +1,205 @@
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
FormControlLabel,
FormGroup,
FormLabel,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
const defaultValues: VendorFormValues = {
companyName: "",
contactName: "",
email: "",
phone: "",
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
};
export default function VendorFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: vendor, isLoading, error } = useVendorDetail(id);
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
const createVendor = useCreateVendor();
const updateVendor = useUpdateVendor();
const {
register,
handleSubmit,
reset,
setValue,
control,
formState: { errors },
} = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema),
defaultValues,
});
useEffect(() => {
if (vendor && isEdit) {
reset({
companyName: vendor.companyName,
contactName: vendor.contactName,
email: vendor.email,
phone: vendor.phone,
address: vendor.address,
city: vendor.city,
state: vendor.state,
zip: vendor.zip,
tradeSpecialties: vendor.tradeSpecialties,
isActive: vendor.isActive,
});
}
}, [vendor, isEdit, reset]);
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
const selectedTrades = (tradeSpecialties || "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
const toggleTrade = (tradeValue: string) => {
const updated = selectedTrades.includes(tradeValue)
? selectedTrades.filter((trade) => trade !== tradeValue)
: [...selectedTrades, tradeValue];
setValue("tradeSpecialties", updated.join(", "));
};
const isSaving = createVendor.isPending || updateVendor.isPending;
const onSubmit = (values: VendorFormValues) => {
if (isEdit && id) {
updateVendor.mutate({ id, values });
return;
}
createVendor.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<Typography variant="subtitle2" className="font-semibold uppercase">
General Details
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Company Name"
required
fullWidth
error={Boolean(errors.companyName)}
helperText={errors.companyName?.message}
{...register("companyName")}
/>
<TextField label="Contact Name" fullWidth {...register("contactName")} />
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Email"
required
fullWidth
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
{...register("email")}
/>
<TextField label="Phone" fullWidth {...register("phone")} />
</Stack>
<Typography variant="subtitle2" className="font-semibold uppercase">
Address
</Typography>
<TextField label="Address" fullWidth {...register("address")} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField label="City" fullWidth {...register("city")} />
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
<TextField label="Zip" fullWidth {...register("zip")} />
</Stack>
<FormGroup>
<FormLabel component="legend">Trade Specialties</FormLabel>
<Box className="mt-2 flex flex-wrap gap-2">
{trades.map((trade) => (
<FormControlLabel
key={String(trade.id)}
control={
<Checkbox
checked={selectedTrades.includes(trade.value)}
onChange={() => toggleTrade(trade.value)}
/>
}
label={trade.value}
/>
))}
</Box>
</FormGroup>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="Active"
/>
)}
/>
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,169 @@
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 (
<Paper variant="outlined" className="p-4">
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary" className="mt-2">
Loading portal link…
</Typography>
</Paper>
);
}
if (isError) {
return <Alert severity="error">Failed to load portal token</Alert>;
}
if (!tokenInfo) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="font-semibold">
Vendor Portal
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
No active portal link. Generate one so this vendor can access the portal.
</Typography>
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
Generate Link
</Button>
</Paper>
);
}
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor Portal
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
<TextField
fullWidth
size="small"
value={tokenInfo.portalUrl}
slotProps={{ input: { readOnly: true } }}
onFocus={(e) => e.target.select()}
/>
<Button variant="outlined" onClick={() => void copyUrl()}>
Copy
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
{tokenInfo.lastUsedAt
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
: " · Never used"}
</Typography>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
color="warning"
onClick={() => setConfirmAction("rotate")}
disabled={busy}
>
Rotate
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setConfirmAction("revoke")}
disabled={busy}
>
Revoke
</Button>
</Stack>
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Rotate Portal Token</DialogTitle>
<DialogContent>
<DialogContentText>
Rotate the portal token? The previous link will stop working immediately.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button
color="warning"
variant="contained"
onClick={() => {
rotate.mutate();
setConfirmAction(null);
}}
>
Rotate
</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Revoke Portal Tokens</DialogTitle>
<DialogContent>
<DialogContentText>
Revoke all active portal tokens? The vendor will lose access until a new link is
generated.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => {
revoke.mutate();
setConfirmAction(null);
}}
>
Revoke
</Button>
</DialogActions>
</Dialog>
</Paper>
);
}

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