mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge remote-tracking branch 'origin/dev' into feat/ab/playground
This commit is contained in:
commit
05d83a2edc
348 changed files with 38141 additions and 0 deletions
52
.cursor/rules/seahaven-admin.mdc
Normal file
52
.cursor/rules/seahaven-admin.mdc
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
description: SeaHaven Admin — IrisLoan architecture, selective rebuild conventions
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# SeaHaven Admin — IrisLoan conventions
|
||||
|
||||
## Stack
|
||||
|
||||
- Vite SPA + React Router 7, TypeScript strict, Tailwind v4 + MUI
|
||||
- HTTP: Ky with `credentials: "include"` (httpOnly cookies)
|
||||
- Server state: TanStack Query; forms: React Hook Form + Zod
|
||||
- **Do not use** in new code: Next.js, Redux, axios, `apiUtil.js`, `tokenUtility.js`
|
||||
|
||||
## Target structure (`src/`)
|
||||
|
||||
```
|
||||
api/ domain/<feature>/ app/ infra/query-key/ lib/ hooks/
|
||||
components/ui|common|layout/ config/menu.ts providers/ main.tsx
|
||||
```
|
||||
|
||||
- `@/` alias for imports; new files in **kebab-case** (`use-debounce.ts`, `query-key.ts`)
|
||||
- `pages/` is **reference only** for endpoints and business rules — delete after delivery in `domain/` + `app/`
|
||||
|
||||
## HTTP and auth
|
||||
|
||||
- Centralize paths in `api/api-paths.ts` and responses in `api/handle-api-response.ts`
|
||||
- Auth via AuthProvider + cookies; do not create Redux slices for session
|
||||
|
||||
## State and data
|
||||
|
||||
- Cache and mutations: React Query + `infra/query-key/query-key.ts`
|
||||
- Client defaults in `lib/query/query-client.ts`
|
||||
|
||||
## Forms and UI
|
||||
|
||||
- Zod schemas in `domain/<feature>/schemas/`; thin views in `app/`
|
||||
- Utility Tailwind + MUI components; **do not** import `site.css` in new code
|
||||
|
||||
## Feature implementation flow
|
||||
|
||||
1. Consult legacy in `pages/<feature>/` (endpoints, fields, rules)
|
||||
2. Create `domain/<feature>/` (schemas, hooks, use-cases)
|
||||
3. Thin view in `app/` + route in `app/routes.tsx`
|
||||
4. Delete `pages/<feature>/` when the new feature is ready
|
||||
|
||||
## Tooling
|
||||
|
||||
- Strict ESLint only on new architecture `.ts/.tsx`; legacy in `pages/` ignored
|
||||
- Commits: Conventional Commits (`feat(scope): subject`)
|
||||
|
||||
Reference: `docs/ARCHITECTURE_PLAN.md`
|
||||
1
.env
Normal file
1
.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=
|
||||
5
.env.development
Normal file
5
.env.development
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Development API URL (uses proxy)
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Development API Target (for proxy)
|
||||
VITE_API_TARGET=http://localhost:5141
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# API Configuration
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# Backend API base URL (Ky prefixUrl). API route paths do NOT include /api — it comes from here.
|
||||
# Development: use /api (Vite proxies /api to VITE_API_TARGET)
|
||||
#VITE_API_URL=/api
|
||||
|
||||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
4
.env.production
Normal file
4
.env.production
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Production API base — the SPA calls the backend directly over HTTPS.
|
||||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# All changes require an approving review from the internal-dev team.
|
||||
# Enforced via the org "main branch protection" ruleset (require_code_owner_review).
|
||||
* @Sea-Haven-Industries/internal-dev
|
||||
16
.github/workflows/ci.yaml
vendored
Normal file
16
.github/workflows/ci.yaml
vendored
Normal 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
38
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Deploy
|
||||
|
||||
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
|
||||
#
|
||||
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
|
||||
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
|
||||
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
|
||||
# CloudFront. Both run as the OIDC deploy role created by the stack.
|
||||
#
|
||||
# When staging/prod accounts exist, add jobs keyed to their branches and their
|
||||
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
||||
# be granted to the reusable workflow unless the caller has it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
|
||||
with:
|
||||
node-version: "24"
|
||||
region: us-east-1
|
||||
cdk-dir: infra/cdk
|
||||
stack-name: shoc-frontend-dev
|
||||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx lint-staged
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
dist
|
||||
build
|
||||
node_modules
|
||||
coverage
|
||||
package-lock.json
|
||||
*.tsbuildinfo
|
||||
|
||||
# Legacy — format incrementally when touched (lint-staged)
|
||||
src/pages/**
|
||||
src/**/*.js
|
||||
src/**/*.jsx
|
||||
src/**/*.css
|
||||
!src/styles/globals.css
|
||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.mdc",
|
||||
"options": {
|
||||
"parser": "markdown"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
96
README.md
Normal file
96
README.md
Normal 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
1
commitlint.config.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
41
config/api-url-contract.test.ts
Normal file
41
config/api-url-contract.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertAbsoluteApiUrlHasApiSegment } from "./api-url-contract";
|
||||
|
||||
describe("assertAbsoluteApiUrlHasApiSegment", () => {
|
||||
it("allows relative /api", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("/api")).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows empty or undefined", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment(undefined)).not.toThrow();
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("")).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows absolute URL ending with /api", () => {
|
||||
expect(() =>
|
||||
assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com/api"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects absolute URL without /api", () => {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com")).toThrow(
|
||||
/must end with \/api/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".env.example contract", () => {
|
||||
it("documents an absolute VITE_API_URL that includes /api", () => {
|
||||
const envExample = readFileSync(path.resolve(process.cwd(), ".env.example"), "utf8");
|
||||
const match = envExample.match(/^VITE_API_URL=(.+)$/m);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
const value = match![1]!.trim();
|
||||
|
||||
if (value.startsWith("http")) {
|
||||
expect(() => assertAbsoluteApiUrlHasApiSegment(value)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
21
config/api-url-contract.ts
Normal file
21
config/api-url-contract.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Absolute VITE_API_URL values must end with this segment (API_PATHS omit /api). */
|
||||
export const ABSOLUTE_API_URL_SUFFIX = /\/api\/?$/;
|
||||
|
||||
/**
|
||||
* Fails fast when an absolute VITE_API_URL omits the /api segment.
|
||||
* Relative values (e.g. /api) are valid and skipped.
|
||||
*/
|
||||
export function assertAbsoluteApiUrlHasApiSegment(apiUrl: string | undefined): void {
|
||||
const trimmed = apiUrl?.trim();
|
||||
if (!trimmed?.startsWith("http")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ABSOLUTE_API_URL_SUFFIX.test(trimmed)) {
|
||||
throw new Error(
|
||||
`VITE_API_URL absolute URLs must end with /api (got: ${trimmed}). ` +
|
||||
"API route paths do not include /api — set VITE_API_URL to the full API base, e.g. https://api.example.com/api. " +
|
||||
"See README.md and .env.example.",
|
||||
);
|
||||
}
|
||||
}
|
||||
102
docs/ARCHITECTURE_PLAN.md
Normal file
102
docs/ARCHITECTURE_PLAN.md
Normal 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
137
docs/DESIGN_SYSTEM.md
Normal 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.
|
||||
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Design System Migration Log
|
||||
|
||||
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to 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
|
||||
160
docs/FEATURE_PRIORITIZATION.md
Normal file
160
docs/FEATURE_PRIORITIZATION.md
Normal 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
544
docs/UI_DOCUMENTATION.md
Normal 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
104
e2e/auth/login.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "test-jwt-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "user@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Test User",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
async function mockDashboardStats(page: Page) {
|
||||
await page.route("**/api/Dashboard/Stats", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
openWorkOrders: 0,
|
||||
pendingApprovals: 0,
|
||||
scheduledPMs: 0,
|
||||
activeAssets: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Login page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem("auth");
|
||||
});
|
||||
await mockDashboardStats(page);
|
||||
});
|
||||
|
||||
test("loads login page with form fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
|
||||
await expect(page.locator("#login-email")).toBeVisible();
|
||||
await expect(page.locator("#login-password")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("disables submit until email and password are valid", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
const submitButton = page.getByRole("button", { name: "Sign In" });
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await page.locator("#login-email").fill("not-an-email");
|
||||
await page.locator("#login-password").fill("secret123");
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("shows error message for invalid credentials", async ({ page }) => {
|
||||
await page.route("**/api/Authentication/Login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Invalid credentials" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await page.locator("#login-password").fill("wrong-password");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByText("Invalid email or password. Please try again.")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("logs in successfully and navigates to dashboard", async ({ page }) => {
|
||||
await page.route("**/api/Authentication/Login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: TEST_USER }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||
await page.locator("#login-password").fill("secret123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible();
|
||||
|
||||
const storedAuth = await page.evaluate(() => localStorage.getItem("auth"));
|
||||
expect(storedAuth).toContain(TEST_USER.token);
|
||||
expect(storedAuth).toContain(TEST_USER.fullname);
|
||||
});
|
||||
});
|
||||
64
eslint.config.js
Normal file
64
eslint.config.js
Normal file
File diff suppressed because one or more lines are too long
28
index.html
Normal file
28
index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#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
161
infra/cdk/README.md
Normal 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
40
infra/cdk/bin/app.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env node
|
||||
import { App, Tags } from "aws-cdk-lib";
|
||||
import { FrontendStack } from "../lib/frontend-stack";
|
||||
|
||||
const app = new App();
|
||||
|
||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
19
infra/cdk/cdk.json
Normal file
19
infra/cdk/cdk.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
|
||||
"watch": {
|
||||
"include": ["**"],
|
||||
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"]
|
||||
},
|
||||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
|
||||
|
||||
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
|
||||
"domainNames": "dev.seahaven.com",
|
||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
||||
"hostedZoneName": "dev.seahaven.com"
|
||||
}
|
||||
}
|
||||
238
infra/cdk/lib/frontend-stack.ts
Normal file
238
infra/cdk/lib/frontend-stack.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
readonly envName: string;
|
||||
/** GitHub repo in owner/name form, for OIDC trust scoping. */
|
||||
readonly githubRepo: string;
|
||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||
readonly deployBranch: string;
|
||||
/**
|
||||
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
||||
* Empty = serve on the default *.cloudfront.net domain.
|
||||
*/
|
||||
readonly domainNames: string[];
|
||||
/**
|
||||
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
|
||||
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
|
||||
* use a certificate from another account, so for Option B the cert must live
|
||||
* in whichever account this stack deploys to.
|
||||
*/
|
||||
readonly certificateArn: string;
|
||||
/**
|
||||
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
|
||||
* record in. Empty = don't manage DNS (add the record manually). When set,
|
||||
* hostedZoneName must also be provided.
|
||||
*/
|
||||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SPA hosting for the 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
410
infra/cdk/package-lock.json
generated
Normal 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
25
infra/cdk/package.json
Normal 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
25
infra/cdk/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["bin/**/*.ts", "lib/**/*.ts"],
|
||||
"exclude": ["node_modules", "cdk.out"]
|
||||
}
|
||||
12541
package-lock.json
generated
Normal file
12541
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
104
package.json
Normal file
104
package.json
Normal 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
26
playwright.config.ts
Normal 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
BIN
public/BrandingImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
55
scripts/deploy-web.sh
Executable file
55
scripts/deploy-web.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
|
||||
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
|
||||
#
|
||||
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
|
||||
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
|
||||
# the right cache headers, and invalidates CloudFront.
|
||||
#
|
||||
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
|
||||
# so it has no hardcoded resource IDs.
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from .env.production)..."
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
# Everything except index.html: long-lived + immutable, prune stale objects.
|
||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*"
|
||||
|
||||
echo "Web deploy complete."
|
||||
45
src/api/api-error.ts
Normal file
45
src/api/api-error.ts
Normal 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
154
src/api/api-paths.ts
Normal 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
113
src/api/api.ts
Normal 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 };
|
||||
73
src/api/handle-api-response.ts
Normal file
73
src/api/handle-api-response.ts
Normal 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
5
src/app-root.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Routes } from "@/app-routes";
|
||||
|
||||
export function AppRoot() {
|
||||
return <Routes />;
|
||||
}
|
||||
1
src/app-routes.tsx
Normal file
1
src/app-routes.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Routes } from "@/routing";
|
||||
21
src/app/(auth)/_components/auth-card-header.tsx
Normal file
21
src/app/(auth)/_components/auth-card-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AuthPageShellProps = ComponentPropsWithoutRef<"div"> & {
|
||||
cardClassName?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AuthPageShell({
|
||||
children,
|
||||
className,
|
||||
cardClassName,
|
||||
...props
|
||||
}: AuthPageShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-screen w-full items-center justify-center bg-(--color-bg-page) px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-sm rounded-(--radius-lg) border border-border bg-card p-10! shadow-sm",
|
||||
cardClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(auth)/_components/login-form.tsx
Normal file
94
src/app/(auth)/_components/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/_layout.tsx
Normal file
11
src/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { GuestRoute } from "@/components/auth/guest-route";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<GuestRoute>
|
||||
<Outlet />
|
||||
</GuestRoute>
|
||||
);
|
||||
}
|
||||
45
src/app/(auth)/login.tsx
Normal file
45
src/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/(protected)/_layout.tsx
Normal file
36
src/app/(protected)/_layout.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-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>
|
||||
);
|
||||
}
|
||||
182
src/app/(protected)/accounts/_components/account-form.tsx
Normal file
182
src/app/(protected)/accounts/_components/account-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/account-form";
|
||||
221
src/app/(protected)/accounts/index.tsx
Normal file
221
src/app/(protected)/accounts/index.tsx
Normal 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 "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAccount.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
{deleteAccount.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/accounts/new.tsx
Normal file
1
src/app/(protected)/accounts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/account-form";
|
||||
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/asset-form";
|
||||
223
src/app/(protected)/assets/index.tsx
Normal file
223
src/app/(protected)/assets/index.tsx
Normal 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 "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAsset.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteAsset.isPending}
|
||||
>
|
||||
{deleteAsset.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/new.tsx
Normal file
1
src/app/(protected)/assets/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/asset-form";
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
279
src/app/(protected)/calendar/_components/event-form.tsx
Normal file
279
src/app/(protected)/calendar/_components/event-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(protected)/calendar/_layout.tsx
Normal file
13
src/app/(protected)/calendar/_layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Outlet } from "react-router-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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/calendar-unavailable";
|
||||
1
src/app/(protected)/calendar/index.tsx
Normal file
1
src/app/(protected)/calendar/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/calendar-unavailable";
|
||||
1
src/app/(protected)/calendar/new.tsx
Normal file
1
src/app/(protected)/calendar/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/calendar-unavailable";
|
||||
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/contact-form";
|
||||
200
src/app/(protected)/contacts/index.tsx
Normal file
200
src/app/(protected)/contacts/index.tsx
Normal 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 "{deleteTarget?.name}"?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteContact.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteContact.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
deleteContact.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/contacts/new.tsx
Normal file
1
src/app/(protected)/contacts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/contact-form";
|
||||
221
src/app/(protected)/dashboard.tsx
Normal file
221
src/app/(protected)/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
361
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
361
src/app/(protected)/employees/_components/employee-form.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Controller, type Control, type UseFormSetValue } from "react-hook-form";
|
||||
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
|
||||
import { JOB_TITLE_OPTIONS } from "@/domain/employees/constants/job-title-options";
|
||||
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||
|
||||
interface JobTitleSelectProps {
|
||||
control: Control<EmployeeFormValues>;
|
||||
setValue: UseFormSetValue<EmployeeFormValues>;
|
||||
}
|
||||
|
||||
export function JobTitleSelect({ control, setValue }: JobTitleSelectProps) {
|
||||
return (
|
||||
<Controller
|
||||
name="jobTitleId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="jobTitleId-label">Job Title</InputLabel>
|
||||
<Select
|
||||
labelId="jobTitleId-label"
|
||||
label="Job Title"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = String(event.target.value);
|
||||
const nextId = value === "" ? null : Number(value);
|
||||
field.onChange(nextId);
|
||||
const label = JOB_TITLE_OPTIONS.find((option) => option.id === nextId)?.label ?? "";
|
||||
setValue("jobTitle", label, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Select...</em>
|
||||
</MenuItem>
|
||||
{JOB_TITLE_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/employee-form";
|
||||
232
src/app/(protected)/employees/index.tsx
Normal file
232
src/app/(protected)/employees/index.tsx
Normal 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 "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteEmployee.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteEmployee.isPending}
|
||||
>
|
||||
{deleteEmployee.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/new.tsx
Normal file
1
src/app/(protected)/employees/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/employee-form";
|
||||
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal file
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/follow-up-form";
|
||||
234
src/app/(protected)/followups/index.tsx
Normal file
234
src/app/(protected)/followups/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/followups/new.tsx
Normal file
1
src/app/(protected)/followups/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/follow-up-form";
|
||||
221
src/app/(protected)/index.tsx
Normal file
221
src/app/(protected)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/app/(protected)/locations/_components/location-form.tsx
Normal file
267
src/app/(protected)/locations/_components/location-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/location-form";
|
||||
221
src/app/(protected)/locations/index.tsx
Normal file
221
src/app/(protected)/locations/index.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/locations/new.tsx
Normal file
1
src/app/(protected)/locations/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/location-form";
|
||||
210
src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
Normal file
210
src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
5
src/app/(protected)/pmschedules/edit/[id].tsx
Normal file
5
src/app/(protected)/pmschedules/edit/[id].tsx
Normal 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;
|
||||
246
src/app/(protected)/pmschedules/index.tsx
Normal file
246
src/app/(protected)/pmschedules/index.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/pmschedules/new.tsx
Normal file
1
src/app/(protected)/pmschedules/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/pm-schedule-form";
|
||||
297
src/app/(protected)/settings/dropdowns.tsx
Normal file
297
src/app/(protected)/settings/dropdowns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/app/(protected)/settings/task-templates.tsx
Normal file
274
src/app/(protected)/settings/task-templates.tsx
Normal 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 "{form.name}"?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
312
src/app/(protected)/uplifts/index.tsx
Normal file
312
src/app/(protected)/uplifts/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
src/app/(protected)/vendor-pos/[id].tsx
Normal file
345
src/app/(protected)/vendor-pos/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/app/(protected)/vendor-pos/index.tsx
Normal file
281
src/app/(protected)/vendor-pos/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal file
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal file
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal 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
Loading…
Add table
Reference in a new issue