Feat/vite typescript migration (#16)

* chore: add eslint, prettier, husky and commitlint tooling

* ci: add GitHub Actions workflow for lint and build

* build: migrate from CRA to Vite with TypeScript config

* docs: add architecture plan and design system documentation

* fix: scope ESLint to new components and hooks directories

* feat: add HTTP client, query cache and shared utilities

* feat: add theme system and global application styles

* feat: add shared UI, layout and domain badge components

* feat: add auth domain, provider and login page

* feat: add app shell, file-based routing and bootstrap

* feat: add protected layout and dashboard module

* feat: add accounts CRUD module

* feat: add assets CRUD module

* feat: add contacts CRUD module

* feat: add employees CRUD module

* feat: add locations CRUD module

* feat: add calendar events module

* feat: add follow-ups CRUD module

* feat: add PM schedules CRUD module

* feat: add work orders module with dispatch modals

* feat: add vendors CRUD and portal token panel

* feat: add vendor purchase orders module

* feat: add uplifts queue module

* feat: add settings for dropdowns and task templates

* feat: add vendor portal routes and signature capture

* test: add Vitest setup and Playwright login e2e spec

* chore: remove legacy CRA pages, Redux store and JS hooks

* chore: update gitignore and env example for Vite

* docs: translate pt-BR docs and Cursor rules to English

* fix(ci): fix login e2e session mock and prettier formatting

* fix(build): use mjs router script for Node 20 CI compatibility

* chore: remove migration scripts and unused generouted router

* fix(auth): restore JWT and align CRUD with backend routes

* fix(api): add no-content helpers and align paths with backend

* fix(accounts): align detail and mutation payloads with backend

* fix(contacts): resolve detail via GetContacts and map address DTOs

* fix(work-orders): align routes, delete body, and create payload

* fix(vendors): delete vendors via REST route

* fix(pm-schedules): handle empty save and delete responses

* fix(employees): add fallbacks for detail and dropdown calls

* chore(calendar): disable routes until backend exists

* chore(env): switch tracked env vars to Vite prefixes

* fix(api): align frontend contracts with backend review findings

Correct Work Order getById query param, asset site options via Location API,
Employee JobTitleId payload, and remove stale API paths.

* fix(employees,pm-schedules): align forms with backend API contracts

Align PM Schedule form and save payload with PMSchedule_DTO fields.
Bind employee Job Title select to jobTitleId for create/update payloads.
Add regression tests for both flows.

* fix(pm-schedules): gate edit/delete for Dev API contract

Production Dev API exposes only GetList and Save.

Hide unsupported edit/delete UI and block the edit route.

Add regression tests for disabled actions.

* docs(env): document VITE_API_URL must include /api suffix

* refactor(api): extract shared API prefix URL resolution

* feat(build): fail build on misconfigured absolute VITE_API_URL

* test(api): cover API URL contract and prefix resolution

* feat(auth): disable login submit until email and password are valid

* test(auth): align login tests with disabled submit behavior

* Feat/ab/menu-and-header (#17)

* chore(deps): add lucide-react for layout icon migration

* feat(auth): add getPrimaryUserRole helper for header display

* style(theme): add sidebar active tokens and nav group typography

* refactor(menu): migrate nav icons to lucide and trim menu groups

* feat(layout): redesign sidebar, topbar, and admin shell viewport

* chore(menu): hide Reports and Documents from sidebar

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>

* ci: add frontend PR quality baseline (#18)

* chore(deps): add lucide-react for layout icon migration

* feat(auth): add getPrimaryUserRole helper for header display

* style(theme): add sidebar active tokens and nav group typography

* refactor(menu): migrate nav icons to lucide and trim menu groups

* feat(layout): redesign sidebar, topbar, and admin shell viewport

* chore(menu): hide Reports and Documents from sidebar

* ci: add PR quality baseline checks

* ci: avoid self-matching standards guard

* ci: split frontend quality gates

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
Arthur Bassi 2026-06-18 14:41:17 -03:00 • committed by GitHub
parent 1632f1e656
commit a0cf7b9ef0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
479 changed files with 29821 additions and 46928 deletions

View file

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

3
.env
View file

@ -1,2 +1 @@
REACT_APP_API_URL=
VITE_API_URL=

View file

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

View file

@ -1,13 +1,12 @@
# API Configuration
# Copy this file to .env and update with your actual values
# Backend API Base URL (without /api suffix)
# For local development, this should point to your ASP.NET Core API
#REACT_APP_API_URL=http://localhost:5000
# 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
# For production, update to your production API URL
REACT_APP_API_URL=https://api.seahavenindustries.com
# Note: The /api prefix is added in the service files
# Example: ${REACT_APP_API_URL}/api/Authentication/login
# 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

View file

@ -1,2 +1,2 @@
# Production API URL
REACT_APP_API_URL=http://console.seahavenind.com/api
VITE_API_URL=http://console.seahavenind.com/api

141
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,141 @@
name: CI
on:
push:
branches: [main, master, develop, Dev]
pull_request:
permissions:
contents: read
concurrency:
group: frontend-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
standards:
name: Metadata and standards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Verify required npm scripts
run: |
node <<'NODE'
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const required = ["format:check", "lint", "build", "test", "test:e2e"];
const missing = required.filter((script) => !pkg.scripts?.[script]);
if (missing.length > 0) {
console.error(`Missing required scripts: ${missing.join(", ")}`);
process.exit(1);
}
NODE
- name: Guard changed lines
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="${{ github.event.pull_request.base.sha }}"
else
BASE_REF="${{ github.event.before }}"
fi
if [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "0000000000000000000000000000000000000000" ]; then
BASE_REF="$(git rev-parse HEAD~1 2>/dev/null || true)"
fi
if [ -z "${BASE_REF}" ]; then
echo "No base ref available; skipping changed-line guard."
exit 0
fi
ADDED_LINES="$(git diff --unified=0 "${BASE_REF}" "${GITHUB_SHA}" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
if printf '%s\n' "${ADDED_LINES}" | grep -E 'Generated with (Claude [C]ode|[C]odex|Chat[G]PT)|--no-[v]erify|HUSKY[=]0'; then
echo "Found generated-tool footer or hook bypass wording in added lines."
exit 1
fi
if printf '%s\n' "${ADDED_LINES}" | grep -E 'A[K]IA[0-9A-Z]{16}|A[S]IA[0-9A-Z]{16}|AWS[_]SECRET[_]ACCESS[_]KEY|aws[_-]?secret[_-]?access[_-]?key|J[W]TAuthenticationHIGHsecuredPassword|P[a]ssword=|S[e]ndGrid.*A[p]iKey'; then
echo "Found a likely secret in added lines. Move sensitive values to the environment or secret manager."
exit 1
fi
code-quality:
name: Code quality
runs-on: ubuntu-latest
needs: standards
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: Build
runs-on: ubuntu-latest
needs: standards
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
needs: standards
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
e2e:
name: Browser smoke
runs-on: ubuntu-latest
needs: standards
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
env:
CI: true

13
.gitignore vendored
View file

@ -8,8 +8,13 @@
# testing
/coverage
/test-results
/playwright-report
/blob-report
/playwright/.cache
# production
/dist
/build
# misc
@ -24,4 +29,10 @@ yarn-debug.log*
yarn-error.log*
db.txt
seed-data.sql
/backend
/backend
# eslint
.eslintcache
# typescript
*.tsbuildinfo

1
.husky/commit-msg Normal file
View file

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

1
.husky/pre-commit Normal file
View file

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

13
.prettierignore Normal file
View file

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

15
.prettierrc Normal file
View file

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

View file

@ -1,70 +1,62 @@
# Getting Started with Create React App
# SeaHaven Admin
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
Vite + React SPA for SeaHaven facility management (work orders, vendor portal, uplifts, and related admin features).
## Available Scripts
## Requirements
In the project directory, you can run:
- Node.js 20+
- npm 10+
### `npm start`
## Setup
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
```bash
npm ci
cp .env.example .env
```
The page will reload when you make changes.\
You may also see any lint errors in the console.
### Environment variables
### `npm test`
| 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` |
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
`VITE_API_URL` is the full API base prefix. Route paths in `API_PATHS` do **not** include `/api` — only this variable supplies it.
### `npm run build`
- **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.
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
See [`.env.example`](.env.example) and [`.env.production`](.env.production) for reference values.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
## Scripts
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
| 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) |
### `npm run eject`
## Architecture
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
New code lives under `src/domain/`, `src/app/`, and `src/api/` following the IrisLoan conventions documented in `docs/ARCHITECTURE_PLAN.md`.
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
## CI
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push and pull requests:
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
1. `npm run format:check`
2. `npm run lint`
3. `npm run build`
4. `npm test`
## Learn More
Local pre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files.
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
## Development proxy
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).

1
commitlint.config.js Normal file
View file

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

View file

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

View file

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

102
docs/ARCHITECTURE_PLAN.md Normal file
View file

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

137
docs/DESIGN_SYSTEM.md Normal file
View file

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

View file

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

View file

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

View file

@ -112,16 +112,19 @@ src/
### 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)
@ -129,18 +132,21 @@ src/
### 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
@ -150,13 +156,13 @@ import styles from './Component.module.css';
```javascript
// ❌ Bad
queryKey: ['pmschedules']
setTimeout(fn, 600)
queryKey: ["pmschedules"];
setTimeout(fn, 600);
// ✅ Good
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from '../constants';
queryKey: [PM_SCHEDULES_LIST]
setTimeout(fn, DEBOUNCE_SEARCH)
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from "../constants";
queryKey: [PM_SCHEDULES_LIST];
setTimeout(fn, DEBOUNCE_SEARCH);
```
---
@ -164,27 +170,30 @@ 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';
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
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>)}
{data.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
@ -192,8 +201,9 @@ function PMScheduleList() {
```
### Example 2: Using Redux Auth
```javascript
import { useAuth } from '../hooks/useAuth';
import { useAuth } from "../hooks/useAuth";
function MyComponent() {
const { user, isAuthenticated, login, logout } = useAuth();
@ -204,16 +214,16 @@ function MyComponent() {
return (
<div>
Welcome {user.name}!
<button onClick={logout}>Logout</button>
Welcome {user.name}!<button onClick={logout}>Logout</button>
</div>
);
}
```
### Example 3: Using Modals
```javascript
import { useModal } from '../hooks/useModal';
import { useModal } from "../hooks/useModal";
function MyComponent() {
const deleteModal = useModal();
@ -223,9 +233,7 @@ function MyComponent() {
<button onClick={() => deleteModal.open(item)}>Delete</button>
{deleteModal.isOpen && (
<Modal onClose={deleteModal.close}>
Delete {deleteModal.data.name}?
</Modal>
<Modal onClose={deleteModal.close}>Delete {deleteModal.data.name}?</Modal>
)}
</div>
);
@ -233,8 +241,9 @@ function MyComponent() {
```
### Example 4: CSS Modules
```javascript
import styles from './LoginPage.module.css';
import styles from "./LoginPage.module.css";
function LoginPage() {
return (
@ -260,6 +269,7 @@ function LoginPage() {
## 🔧 Common Patterns
### Pattern 1: Fetch and Display Data
```javascript
const { data, isLoading, error } = useDataHook(params);
@ -270,6 +280,7 @@ return <Display data={data} />;
```
### Pattern 2: Delete with Confirmation
```javascript
const deleteMutation = useDeleteHook();
const deleteModal = useModal();
@ -286,6 +297,7 @@ const handleDelete = async () => {
```
### Pattern 3: Search with Debounce
```javascript
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
@ -300,42 +312,48 @@ const { data } = useDataHook({ search: debouncedSearch });
## 📚 Constants Reference
### API Constants
```javascript
API_URL // Backend URL
API_ERROR_MESSAGE // Default error message
API_SUCCESS_MESSAGE // Success message
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
DEBOUNCE_SEARCH; // 600ms
CACHE_TIME_MEDIUM; // 5 minutes
STALE_TIME_MEDIUM; // 5 minutes
```
### View Modes
```javascript
VIEW_MODE_LIST // 'list'
VIEW_MODE_CARD // 'card'
VIEW_MODE_LIST; // 'list'
VIEW_MODE_CARD; // 'card'
```
### Status Types
```javascript
STATUS_OPEN // 'Open'
STATUS_COMPLETED // 'Completed'
STATUS_OPEN; // 'Open'
STATUS_COMPLETED; // 'Completed'
```
### Query Keys
```javascript
PM_SCHEDULES_LIST // 'pmSchedulesList'
WORK_ORDERS_LIST // 'workOrdersList'
EMPLOYEES_DROPDOWN // 'employeesDropdown'
PM_SCHEDULES_LIST; // 'pmSchedulesList'
WORK_ORDERS_LIST; // 'workOrdersList'
EMPLOYEES_DROPDOWN; // 'employeesDropdown'
```
### Storage Keys
```javascript
STORAGE_KEY_TOKEN // 'token'
STORAGE_KEY_THEME // 'theme'
STORAGE_KEY_TOKEN; // 'token'
STORAGE_KEY_THEME; // 'theme'
```
---
@ -343,16 +361,19 @@ 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
```
@ -364,13 +385,16 @@ aws s3 sync build/ s3://shoc-ui-app --acl public-read
### 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));
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
// New
@ -378,6 +402,7 @@ const { data, isLoading } = usePMSchedules({ page: 1 });
```
**Step 2: Use Constants**
```javascript
// Old
const [search, setSearch] = useState("");
@ -387,11 +412,12 @@ useEffect(() => {
}, [search]);
// New
import { DEBOUNCE_SEARCH } from '../constants';
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';
@ -403,14 +429,17 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
## ✅ 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
@ -433,6 +462,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
## 📖 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
@ -463,15 +493,19 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
## 🛠️ 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
---
@ -479,6 +513,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
## 🎉 Summary
### What We Built:
- ✅ Modern React architecture
- ✅ Redux Toolkit for global state
- ✅ React Query for server state
@ -487,6 +522,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
- ✅ Custom hooks for reusable logic
### Benefits:
- 📉 70% less boilerplate code
- 🚀 Automatic caching and refetching
- 🎨 No CSS naming conflicts
@ -495,6 +531,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
- ⚡ 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
@ -504,5 +541,4 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
**Welcome to the modern SeaHaven UI! 🎊**
*Last updated: 2026*
_Last updated: 2026_

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

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

62
eslint.config.js Normal file
View file

@ -0,0 +1,62 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
const legacyIgnores = [
"src/pages/**",
"src/app/store.js",
"src/app/slices/**",
"src/lib/queryClient.js",
"src/hooks/**/*.js",
"src/components/*.{js,css}",
"src/constants/**",
"src/layouts/**",
"src/**/*.js",
"src/**/*.jsx",
];
export default tseslint.config(
{
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: [
"src/{api,domain,app,lib,providers,infra,config,hooks,components/auth}/**/*.{ts,tsx}",
"src/**/*.{ts,tsx}",
"vite.config.ts",
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
},
{
files: ["vite.config.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: globals.node,
},
},
);

28
index.html Normal file
View file

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

30198
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,55 +2,103 @@
"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",
"@reduxjs/toolkit": "^2.11.2",
"@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",
"axios": "^1.16.0",
"http-proxy-middleware": "^3.0.5",
"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-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"web-vitals": "^2.1.4"
"tailwind-merge": "^3.6.0",
"web-vitals": "^2.1.4",
"zod": "^4.4.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"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"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"engines": {
"node": ">=20"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

26
playwright.config.ts Normal file
View file

@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View file

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/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" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Seahaven - Officetrax</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -1,179 +0,0 @@
import React, { useState } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Sidebar from "./components/Sidebar";
import Topbar from "./components/Topbar";
import Dashboard from "./pages/Dashboard";
// AUTH
import LoginPage from "./pages/auth/LoginPage";
import { isAuthenticated } from "./services/authService";
// CONTACTS
import ContactsList from "./pages/contacts/list/List";
import ContactFormPage from "./pages/contacts/form/ContactFormPage";
// ACCOUNTS
import AccountList from "./pages/accounts/list/List";
import AccountFormPage from "./pages/accounts/form/AccountFormPage";
// EMPLOYEES
import EmployeeList from "./pages/employees/list/List";
import EmployeeFormPage from "./pages/employees/form/EmployeeFormPage";
// LOCATIONS
import LocationsList from "./pages/locations/list/List";
import LocationFormPage from "./pages/locations/form/LocationFormPage";
// WORK ORDERS
import WorkOrdersList from "./pages/workorders/list/List";
import WorkOrderViewPage from "./pages/workorders/view/WorkOrderViewPage";
import WorkOrderFormPage from "./pages/workorders/form/WorkOrderFormPage";
// FOLLOW UPS
import FollowUpList from "./pages/followup/list/List";
import FollowUpFormPage from "./pages/followup/form/FollowUpFormPage";
// PM SCHEDULES
import PMScheduleList from "./pages/PmSchedule/list/List";
import PMScheduleFormPage from "./pages/PmSchedule/form/PmScheduleFormPage";
// ASSETS
import AssetsList from "./pages/assets/list/List";
import AssetFormPage from "./pages/assets/form/AssetFormPage";
// CALENDAR
import CalendarPage from "./pages/calendar/list/CalendarPage";
import EventFormPage from "./pages/calendar/form/EventFormPage";
// VENDORS
import VendorsList from "./pages/vendors/list/List";
import VendorFormPage from "./pages/vendors/form/VendorFormPage";
// UPLIFTS
import UpliftQueuePage from "./pages/uplifts/UpliftQueuePage";
// VENDOR POs
import VendorPoList from "./pages/vendor-pos/list/List";
import VendorPoViewPage from "./pages/vendor-pos/view/VendorPoViewPage";
// SETTINGS
import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage";
import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage";
// VENDOR PORTAL (public, token-authenticated, separate tree)
import VendorPortalLayout from "./pages/vendor-portal/VendorPortalLayout";
import VendorPortalDashboard from "./pages/vendor-portal/DashboardPage";
import VendorPortalPos from "./pages/vendor-portal/PosPage";
import VendorPortalDispatchDetail from "./pages/vendor-portal/DispatchDetailPage";
// Protected Route Component
function ProtectedRoute({ children }) {
return isAuthenticated() ? children : <Navigate to="/login" replace />;
}
export default function App() {
const [navCollapsed, setNavCollapsed] = useState(false);
return (
<BrowserRouter>
<Routes>
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
{/* Vendor Portal — public, token-authenticated via URL */}
<Route path="/v/:token" element={<VendorPortalLayout />}>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<VendorPortalDashboard />} />
<Route path="pos" element={<VendorPortalPos />} />
<Route path="dispatch/:id" element={<VendorPortalDispatchDetail />} />
</Route>
{/* Protected Routes */}
<Route
path="/*"
element={
<ProtectedRoute>
<div className={`app ${navCollapsed ? "nav-collapsed" : ""}`}>
<Sidebar />
<div className="mainArea">
<Topbar onToggleNav={() => setNavCollapsed(v => !v)} />
<div className="contentWrapper">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* CONTACTS */}
<Route path="/contacts" element={<ContactsList />} />
<Route path="/contacts/new" element={<ContactFormPage />} />
<Route path="/contacts/edit/:id" element={<ContactFormPage />} />
{/* ACCOUNTS */}
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/new" element={<AccountFormPage />} />
<Route path="/accounts/edit/:id" element={<AccountFormPage />} />
{/* EMPLOYEES */}
<Route path="/employees" element={<EmployeeList />} />
<Route path="/employees/new" element={<EmployeeFormPage />} />
<Route path="/employees/edit/:id" element={<EmployeeFormPage />} />
{/* LOCATIONS */}
<Route path="/locations" element={<LocationsList />} />
<Route path="/locations/new" element={<LocationFormPage />} />
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
{/* WORK ORDERS */}
<Route path="/workorders" element={<WorkOrdersList />} />
<Route path="/workorders/new" element={<WorkOrderFormPage />} />
<Route path="/workorders/:id" element={<WorkOrderViewPage />} />
<Route path="/workorders/edit/:id" element={<WorkOrderFormPage />} />
{/* FOLLOW UPS */}
<Route path="/followups" element={<FollowUpList />} />
<Route path="/followups/new" element={<FollowUpFormPage />} />
<Route path="/followups/edit/:id" element={<FollowUpFormPage />} />
{/* PM SCHEDULES */}
<Route path="/pmschedules" element={<PMScheduleList />} />
<Route path="/pmschedules/new" element={<PMScheduleFormPage />} />
<Route path="/pmschedules/edit/:id" element={<PMScheduleFormPage />} />
{/* ASSETS */}
<Route path="/assets" element={<AssetsList />} />
<Route path="/assets/new" element={<AssetFormPage />} />
<Route path="/assets/edit/:id" element={<AssetFormPage />} />
{/* VENDORS */}
<Route path="/vendors" element={<VendorsList />} />
<Route path="/vendors/new" element={<VendorFormPage />} />
<Route path="/vendors/edit/:id" element={<VendorFormPage />} />
{/* CALENDAR */}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/calendar/new" element={<EventFormPage />} />
<Route path="/calendar/edit/:id" element={<EventFormPage />} />
{/* UPLIFTS */}
<Route path="/uplifts" element={<UpliftQueuePage />} />
{/* VENDOR POs */}
<Route path="/vendor-pos" element={<VendorPoList />} />
<Route path="/vendor-pos/:id" element={<VendorPoViewPage />} />
{/* SETTINGS */}
<Route path="/settings/dropdowns" element={<DropdownOptionsPage />} />
<Route path="/settings/task-templates" element={<TaskTemplatesPage />} />
</Routes>
</div>
</div>
</div>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -1,45 +0,0 @@
import React from "react";
import { Container } from "@mui/material";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import "./site.css";
import TopNav from "./components/TopNav";
import LeftNav from "./components/LeftNav";
import Main from "./components/Main";
const THEME = createTheme({
typography: {
fontFamily: `"Dosis", "Nunito", sans-serif`,
fontSize: 14,
fontWeightLight: 300,
fontWeightRegular: 400,
fontWeightMedium: 500,
},
palette: {
header: {
main: "#4D81B7",
contrastText: "header background",
},
},
flexBox: {
display: "flex",
},
});
const Layout = (props) => {
const [open, setOpen] = React.useState(false);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<ThemeProvider theme={THEME}>
<TopNav handleDrawerOpen={handleDrawerOpen}/>
<LeftNav handleDrawerClose= {handleDrawerClose} open={open}/>
<Main open={open} maxWidth="lg">{props.children}</Main>
</ThemeProvider>
)};
export default Layout;

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

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

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

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

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

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

View file

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

View file

@ -1,76 +0,0 @@
import { API_ERROR_MESSAGE } from "./constants";
import { getAccessToken } from "./tokenUtility";
const getHeaders = () => ({
Accept: "application/json",
"Content-Type": "application/json",
pragma: "no-cache",
"cache-control": "no-cache",
Authorization: getAccessToken(),
});
const getFormHeaders = () => ({
accept: "application/json",
pragma: "no-cache",
"cache-control": "no-cache",
Authorization: getAccessToken(),
});
export const get = (url) =>
fetch(url, {
headers: getHeaders(),
method: "GET",
}).then(handleHttpResponse);
export const post = (url, payload) =>
fetch(url, {
headers: getHeaders(),
method: "POST",
body: JSON.stringify(payload),
}).then(handleHttpResponse);
export const postFormBody = (url, payload) => {
const formData = new FormData();
for (const [key, value] of Object.entries(payload)) {
formData.append(key, value);
}
return fetch(url, {
method: "POST",
headers: getFormHeaders(),
body: formData,
}).then(handleHttpResponse);
};
export const put = (url, payload) =>
fetch(url, {
headers: getHeaders(),
method: "PUT",
body: JSON.stringify(payload),
}).then(handleHttpResponse);
export const putFormBody = (url, payload) => {
const formData = new FormData();
for (const [key, value] of Object.entries(payload)) {
formData.append(key, value);
}
return fetch(url, {
method: "PUT",
headers: getFormHeaders(),
body: formData,
}).then(handleHttpResponse);
};
export const deleteItem = (url, payload) =>
fetch(url, {
headers: getHeaders(),
method: "DELETE",
body: JSON.stringify(payload),
}).then(handleHttpResponse);
const handleHttpResponse = (response) => {
if (response.ok) return response.json();
if (response.status === 401) {
throw new Error("You are not authorized to access this page.");
}
throw new Error(API_ERROR_MESSAGE);
};

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { useDebounce } from "@/hooks/use-debounce";
export default function LocationFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const [accountSearch, setAccountSearch] = useState("");
const debouncedAccountSearch = useDebounce(accountSearch, 300);
const { data: location, isLoading, error } = useLocationDetail(id);
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
search: debouncedAccountSearch,
page: 1,
pageSize: 50,
});
const createLocation = useCreateLocation();
const updateLocation = useUpdateLocation();
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: locationDefaultValues,
});
useEffect(() => {
if (location && isEdit) {
reset({
name: location.name ?? "",
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: (location.state as LocationFormValues["state"]) || "IL",
zipCode: location.zipCode ?? "",
phone: location.phone ?? "",
contact: location.contact ?? "",
contactEmail: location.contactEmail ?? "",
status: (location.status as LocationFormValues["status"]) || "Active",
});
}
}, [location, isEdit, reset]);
const isSaving = createLocation.isPending || updateLocation.isPending;
const onSubmit = (values: LocationFormValues) => {
if (isEdit && id) {
updateLocation.mutate({ id, values });
return;
}
createLocation.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
<Button variant="text" onClick={() => navigate("/locations")}>
Back to list
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) =>
String(option.id) === String(value.id)
}
value={
accountOptions.find((account) => String(account.id) === field.value) ?? null
}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setAccountSearch(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<Controller
name="state"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.state)}>
<InputLabel id="state-label">State</InputLabel>
<Select {...field} labelId="state-label" label="State" value={field.value}>
{LOCATION_STATE_OPTIONS.map((state) => (
<MenuItem key={state} value={state}>
{state}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("phone")}
label="Phone"
required
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
fullWidth
/>
<TextField
{...register("contact")}
label="Contact Person"
required
error={Boolean(errors.contact)}
helperText={errors.contact?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={2}>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import PmScheduleFormPage from "../_components/pm-schedule-form";
import PmScheduleEditUnavailablePage from "../_components/pm-schedule-unavailable";
export default pmScheduleCapabilities.canEdit ? PmScheduleFormPage : PmScheduleEditUnavailablePage;

View file

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

View file

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

View file

@ -0,0 +1,297 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { SettingsNav } from "@/components/common/settings-nav";
import {
DROPDOWN_OPTION_CATEGORIES,
type DropdownOption,
type DropdownOptionCategory,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
import { useCreateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-create-dropdown-option";
import { useDeleteDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { useUpdateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-update-dropdown-option";
export default function DropdownOptionsPage() {
const [activeCategory, setActiveCategory] = useState<DropdownOptionCategory>("Trade");
const [newValue, setNewValue] = useState("");
const [newParent, setNewParent] = useState("");
const [editingId, setEditingId] = useState<string | number | null>(null);
const [editValue, setEditValue] = useState("");
const categoryConfig = useMemo(
() => DROPDOWN_OPTION_CATEGORIES.find((category) => category.key === activeCategory),
[activeCategory],
);
const parentCategory =
categoryConfig && "parent" in categoryConfig ? categoryConfig.parent : undefined;
const { data: options = [], isLoading, error } = useDropdownOptionsByCategory(activeCategory);
const { data: parentOptions = [] } = useDropdownOptionsByCategory("Trade", {
enabled: Boolean(parentCategory),
});
const createMutation = useCreateDropdownOption();
const updateMutation = useUpdateDropdownOption();
const deleteMutation = useDeleteDropdownOption();
const resetFormState = () => {
setNewValue("");
setNewParent("");
setEditingId(null);
setEditValue("");
};
const handleCategoryChange = (category: DropdownOptionCategory) => {
if (category !== activeCategory) {
resetFormState();
}
setActiveCategory(category);
};
const handleAdd = () => {
const value = newValue.trim();
if (!value) {
return;
}
createMutation.mutate(
{
category: activeCategory,
value,
parentValue: parentCategory ? newParent || null : null,
sortOrder: options.length,
isActive: true,
},
{
onSuccess: () => {
setNewValue("");
setNewParent("");
},
},
);
};
const handleEditSave = (item: DropdownOption) => {
const value = editValue.trim();
if (!value) {
return;
}
updateMutation.mutate(
{
id: item.id,
category: item.category,
value,
parentValue: item.parentValue,
sortOrder: item.sortOrder,
isActive: item.isActive,
},
{
onSuccess: () => {
setEditingId(null);
},
},
);
};
const handleDelete = (id: string | number) => {
deleteMutation.mutate(id);
};
const addPlaceholder = `Add new ${categoryConfig?.label.slice(0, -1) ?? "option"}...`;
const isMutating =
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
return (
<Box className="flex flex-col gap-4 p-4">
<SettingsNav />
<Stack spacing={0.5}>
<Typography variant="h5" component="h1">
Dropdown Options
</Typography>
<Typography variant="body2" color="text.secondary">
Manage configurable dropdown values for work orders
</Typography>
</Stack>
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
<Button
key={category.key}
onClick={() => handleCategoryChange(category.key)}
className={`justify-start rounded-none border-b border-border px-4 py-3 text-left normal-case ${
activeCategory === category.key
? "border-r-2 border-r-primary bg-background font-semibold text-primary"
: "font-medium text-muted-foreground hover:bg-muted/50"
}`}
>
{category.label}
</Button>
))}
</Stack>
<Box className="flex flex-1 flex-col gap-4 p-4">
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
{parentCategory && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select
labelId="parent-trade-label"
label="Parent Trade"
value={newParent}
onChange={(event) => setNewParent(event.target.value)}
>
<MenuItem value="">Parent: All</MenuItem>
{parentOptions.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
size="small"
value={newValue}
onChange={(event) => setNewValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAdd();
}
}}
placeholder={addPlaceholder}
className="flex-1"
/>
<Button
variant="contained"
onClick={handleAdd}
disabled={!newValue.trim() || isMutating}
className="shrink-0"
>
Add
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"}
</Alert>
)}
{isLoading ? (
<Stack alignItems="center" className="py-10">
<CircularProgress size={28} />
</Stack>
) : options.length === 0 ? (
<Typography align="center" color="text.secondary" className="py-10">
No options configured yet
</Typography>
) : (
<Stack spacing={1}>
{options.map((item) => (
<Paper
key={String(item.id)}
variant="outlined"
className="flex items-center justify-between gap-3 px-3 py-2"
>
{editingId === item.id ? (
<TextField
size="small"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleEditSave(item);
}
if (event.key === "Escape") {
setEditingId(null);
}
}}
autoFocus
className="flex-1"
/>
) : (
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
<Typography variant="body2" className="truncate">
{item.value}
</Typography>
{item.parentValue && (
<Chip size="small" label={item.parentValue} variant="outlined" />
)}
</Stack>
)}
<Stack direction="row" spacing={1} className="shrink-0">
{editingId === item.id ? (
<>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handleEditSave(item)}
disabled={isMutating}
>
Save
</Button>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setEditingId(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
variant="outlined"
onClick={() => {
setEditingId(item.id);
setEditValue(item.value);
}}
>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleDelete(item.id)}
disabled={isMutating}
>
Delete
</Button>
</>
)}
</Stack>
</Paper>
))}
</Stack>
)}
</Box>
</Paper>
</Box>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,169 @@
import { useState } from "react";
import {
Alert,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
useGenerateVendorPortalToken,
useRevokeVendorPortalToken,
useRotateVendorPortalToken,
useVendorPortalToken,
} from "@/domain/vendors/use-cases/use-vendor-portal-token";
interface VendorPortalTokenPanelProps {
vendorId: string | number;
}
export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps) {
const { data: tokenInfo, isLoading, isError } = useVendorPortalToken(vendorId);
const rotate = useRotateVendorPortalToken(vendorId);
const revoke = useRevokeVendorPortalToken(vendorId);
const generate = useGenerateVendorPortalToken(vendorId);
const [confirmAction, setConfirmAction] = useState<"rotate" | "revoke" | null>(null);
const busy = rotate.isPending || revoke.isPending || generate.isPending;
const copyUrl = async () => {
if (!tokenInfo?.portalUrl) return;
try {
await navigator.clipboard.writeText(tokenInfo.portalUrl);
} catch {
// clipboard may be unavailable
}
};
if (isLoading) {
return (
<Paper variant="outlined" className="p-4">
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary" className="mt-2">
Loading portal link…
</Typography>
</Paper>
);
}
if (isError) {
return <Alert severity="error">Failed to load portal token</Alert>;
}
if (!tokenInfo) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="font-semibold">
Vendor Portal
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
No active portal link. Generate one so this vendor can access the portal.
</Typography>
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
Generate Link
</Button>
</Paper>
);
}
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor Portal
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
<TextField
fullWidth
size="small"
value={tokenInfo.portalUrl}
slotProps={{ input: { readOnly: true } }}
onFocus={(e) => e.target.select()}
/>
<Button variant="outlined" onClick={() => void copyUrl()}>
Copy
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
{tokenInfo.lastUsedAt
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
: " · Never used"}
</Typography>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
color="warning"
onClick={() => setConfirmAction("rotate")}
disabled={busy}
>
Rotate
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setConfirmAction("revoke")}
disabled={busy}
>
Revoke
</Button>
</Stack>
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Rotate Portal Token</DialogTitle>
<DialogContent>
<DialogContentText>
Rotate the portal token? The previous link will stop working immediately.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button
color="warning"
variant="contained"
onClick={() => {
rotate.mutate();
setConfirmAction(null);
}}
>
Rotate
</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Revoke Portal Tokens</DialogTitle>
<DialogContent>
<DialogContentText>
Revoke all active portal tokens? The vendor will lose access until a new link is
generated.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => {
revoke.mutate();
setConfirmAction(null);
}}
>
Revoke
</Button>
</DialogActions>
</Dialog>
</Paper>
);
}

View file

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

215
src/app/(protected)/vendors/index.tsx vendored Normal file
View file

@ -0,0 +1,215 @@
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 { VendorListItem } from "@/domain/vendors/types/vendor";
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
import { useDebounce } from "@/hooks/use-debounce";
export default function VendorsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteVendor = useDeleteVendor();
const listParams = useMemo(
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
[debouncedSearch, page, pageSize],
);
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteVendor.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">
Vendors
</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 vendors..."
className="min-w-sidebar"
/>
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
+ New Vendor
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendors"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Company</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Email</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Trades</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 vendors found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
>
{row.companyName || "—"}
</Button>
</TableCell>
<TableCell>{row.contactName || "—"}</TableCell>
<TableCell>{row.email || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit vendor"
onClick={() => navigate(`/vendors/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete vendor"
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 Vendor</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.companyName}&quot;?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteVendor.isPending}
>
{deleteVendor.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

1
src/app/(protected)/vendors/new.tsx vendored Normal file
View file

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

View file

@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Breadcrumbs,
Button,
Chip,
CircularProgress,
Link,
Menu,
MenuItem,
Paper,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
const STATUS_COLORS: Record<
string,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
Open: "primary",
"In Progress": "info",
"On Hold": "warning",
Done: "success",
Cancelled: "default",
};
type ActivityTab = "customer" | "internal" | "audit";
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
[...comments].sort(
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
),
[comments],
);
if (!sorted.length) {
return <Typography color="text.secondary">No comments yet.</Typography>;
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" className="mb-1">
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
);
}
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return <Typography color="text.secondary">No changes recorded.</Typography>;
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" className="mb-1">
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Stack>
);
}
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
}
if (error || !wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
</Alert>
</Box>
);
}
return (
<Box className="flex flex-col gap-4 p-4">
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography color="text.primary">{wo.internalWONumber}</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "flex-start" }}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => setStatusAnchor(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => setStatusAnchor(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
setStatusAnchor(null);
if (status !== wo.status) {
statusMutation.mutate(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
Dispatch
</Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${id}`)}>
Edit
</Button>
<Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List
</Button>
</Stack>
</Stack>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Internal WO" value={wo.internalWONumber} />
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
<Field label="Priority" value={wo.priority || "—"} />
<Field label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Assigned To
</Typography>
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => setAssignAnchor(null)}
>
<MenuItem
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: "", userName: "" });
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: user.id, userName: user.name });
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
<Field label="Source" value={wo.source || "—"} />
<Field label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="Problem" value={wo.problem || "—"} />
<Field label="Trade" value={wo.trade || "—"} />
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<Field label="Due Date" value={wo.dueDate || "—"} />
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
<Field label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<Field label="Customer PO" value={wo.po || "—"} />
<Field label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const waitColor = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => setDispatchDetailId(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography
variant="caption"
className={
waitColor === "green"
? "text-green-600"
: waitColor === "yellow"
? "text-yellow-600"
: "text-red-600"
}
>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography color="text.secondary">No dispatches yet.</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={commentMutation.isPending}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={commentMutation.isPending || !commentText.trim()}
>
{commentMutation.isPending ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
{dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
</Box>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Box>
);
}

View file

@ -0,0 +1,341 @@
import { useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Radio,
Stack,
TextField,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useTaskListTemplates,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
interface SelectedWorkOrder {
id: string | number;
number: string;
title: string;
}
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
const [description, setDescription] = useState(workOrder.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
title: workOrder.title,
},
]);
const [woSearch, setWoSearch] = useState("");
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
workOrder.locationZip,
);
const { data: taskTemplates = [] } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const siteWorkOrders = useMemo(
() =>
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
[siteWorkOrdersData?.items, workOrder.id],
);
const filteredVendors = useMemo(
() =>
vendors.filter(
(vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
),
[vendors, vendorSearch],
);
const filteredSiteWOs = useMemo(
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.number.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
);
const handleSubmit = () => {
if (!selectedVendorId) {
return;
}
dispatchMutation.mutate(
{
workOrderIds: selectedWOs.map((wo) => wo.id),
vendorIds: [selectedVendorId],
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
scheduledDate: scheduledDate || null,
description,
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
);
};
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{selectedWOs.length > 1
? `${selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
label={`${wo.number} — ${wo.title}`}
onDelete={
String(wo.id) !== String(workOrder.id)
? () => setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id))
: undefined
}
/>
))}
</Stack>
{siteWorkOrders.length > 0 && (
<Box className="mt-3">
<TextField
size="small"
value={woSearch}
onChange={(event) => setWoSearch(event.target.value)}
placeholder="Search to filter..."
fullWidth
className="mb-2"
/>
<PaperList>
{filteredSiteWOs.map((item) => (
<Box
key={String(item.id)}
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
onClick={() => {
setSelectedWOs((prev) => [
...prev,
{ id: item.id, number: item.number, title: item.title },
]);
setWoSearch("");
}}
>
<Typography variant="body2">
<strong>{item.number}</strong> — {item.title}
</Typography>
</Box>
))}
</PaperList>
</Box>
)}
</Box>
<Box>
<Typography variant="subtitle2" className="mb-2">
Select Vendor
</Typography>
<TextField
size="small"
value={vendorSearch}
onChange={(event) => setVendorSearch(event.target.value)}
placeholder="Search vendors..."
fullWidth
className="mb-2"
/>
{isLoadingVendors ? (
<Typography color="text.secondary">Loading vendors...</Typography>
) : filteredVendors.length === 0 ? (
<Typography color="text.secondary">No vendors found</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{filteredVendors.map((vendor) => (
<Box
key={String(vendor.id)}
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
selectedVendorId === vendor.id
? "border-primary bg-primary/10"
: "border-border"
}`}
onClick={() =>
setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id))
}
>
<Radio checked={selectedVendorId === vendor.id} readOnly size="small" />
<Box className="flex-1">
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{vendor.tradeSpecialties && (
<Typography variant="caption" color="text.secondary" display="block">
{vendor.tradeSpecialties}
</Typography>
)}
{vendor.address && (
<Typography variant="caption" color="text.secondary" display="block">
{vendor.address}
</Typography>
)}
</Box>
{vendor.distanceMiles != null && (
<Typography variant="caption" className="whitespace-nowrap">
{vendor.distanceMiles} miles
</Typography>
)}
</Box>
))}
</Stack>
)}
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="NTE Amount"
type="number"
value={nteAmount}
onChange={(event) => setNteAmount(event.target.value)}
inputProps={{ step: "0.01", min: 0 }}
fullWidth
/>
<TextField
label="Vendor Scheduled Date"
type="date"
value={scheduledDate}
onChange={(event) => setScheduledDate(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
fullWidth
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
{customItems.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1}>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
label={item}
onDelete={() => setCustomItems((prev) => prev.filter((_, i) => i !== index))}
/>
))}
</Stack>
)}
<TextField
value={newCustomItem}
onChange={(event) => setNewCustomItem(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newCustomItem.trim()) {
event.preventDefault();
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
setNewCustomItem("");
}
}}
placeholder="Add custom checklist item..."
fullWidth
size="small"
/>
<TextField
label="Description"
multiline
rows={4}
value={description}
onChange={(event) => setDescription(event.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={dispatchMutation.isPending || !selectedVendorId}
>
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
</Button>
</DialogActions>
</Dialog>
);
}
function PaperList({ children }: { children: React.ReactNode }) {
return (
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">{children}</Box>
);
}

View file

@ -0,0 +1,698 @@
import { useMemo, useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Alert,
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Dialog,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { SignatureCapture } from "@/components/common/signature-capture";
import {
useApproveUplift,
useDenyUplift,
useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddChecklistItem,
useAddDispatchComment,
useAddDispatchSignoff,
useUpdateChecklistItem,
useUpdateDispatch,
useVerifyDispatch,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
import { useAuthContext } from "@/providers/auth-context";
import {
formatDateTime,
timeBetween,
timeSince,
toDateInputValue,
waitTimeColor,
} from "@/lib/time-utils";
interface DispatchDetailModalProps {
dispatchId: string | number;
workOrderId: string | number;
onClose: () => void;
onUpdated: () => void;
}
export function DispatchDetailModal({
dispatchId,
workOrderId,
onClose,
onUpdated,
}: DispatchDetailModalProps) {
const { user } = useAuthContext();
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({
status: "",
nteAmount: "",
scheduledDate: "",
completedDate: "",
description: "",
});
const [newChecklistItem, setNewChecklistItem] = useState("");
const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
const addCommentMutation = useAddDispatchComment(dispatchId);
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
const addChecklistMutation = useAddChecklistItem(dispatchId);
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId);
const userName =
(typeof user?.name === "string" ? user.name : undefined) ??
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
"User";
const beginEditing = () => {
if (dispatch) {
setForm({
status: dispatch.status || "",
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
scheduledDate: toDateInputValue(dispatch.scheduledDate),
completedDate: toDateInputValue(dispatch.completedDate),
description: dispatch.description || "",
});
}
setEditing(true);
};
const verifyState = useMemo(() => {
if (!dispatch) {
return { canVerify: false, missing: [] as string[] };
}
const checklistTotal = dispatch.checklistItems.length;
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
const signoffCount = dispatch.signoffs.length;
const missing: string[] = [];
if (checklistTotal > 0 && checklistDone < checklistTotal) {
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
}
if (signoffCount < 2) {
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
if (!types.includes("Customer")) {
missing.push("Customer sign-off");
}
if (!types.includes("Vendor")) {
missing.push("Vendor sign-off");
}
}
return {
missing,
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
};
}, [dispatch]);
const handleSave = () => {
updateDispatchMutation.mutate(
{
id: dispatchId,
status: form.status,
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
scheduledDate: form.scheduledDate || null,
completedDate: form.completedDate || null,
description: form.description,
},
{
onSuccess: () => {
setEditing(false);
onUpdated();
},
},
);
};
const handleComment = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
addCommentMutation.mutate(
{ text: commentText.trim(), sendEmail },
{
onSuccess: () => setCommentText(""),
},
);
};
const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
const note = window.prompt("Optional approval note:", "") || "";
if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
return;
}
approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
};
const handleDenyUplift = (upliftId: string | number) => {
const note = window.prompt("Denial reason (required):", "");
if (!note?.trim()) {
return;
}
denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
};
if (isLoading) {
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12">
<CircularProgress />
</DialogContent>
</Dialog>
);
}
if (!dispatch) {
return null;
}
const isLocked = isDispatchLocked(dispatch);
const waitColor = waitTimeColor(dispatch.dispatchedAt);
return (
<>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
<DialogTitle className="flex items-start justify-between gap-4 pr-2">
<Box>
<Typography variant="h6">
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
</Typography>
<Typography variant="caption" color="text.secondary">
PO: {dispatch.poNumber} ·{" "}
{dispatch.workOrders.length > 0
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
: `WO: ${dispatch.woNumber}`}{" "}
· {dispatch.vendorEmail}
</Typography>
</Box>
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-6">
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{editing ? (
<>
<TextField
label="Status"
select
size="small"
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
{DISPATCH_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
<TextField
label="NTE Amount"
type="number"
size="small"
value={form.nteAmount}
onChange={(event) =>
setForm((current) => ({ ...current, nteAmount: event.target.value }))
}
inputProps={{ step: "0.01" }}
/>
<TextField
label="Scheduled Date"
type="date"
size="small"
value={form.scheduledDate}
onChange={(event) =>
setForm((current) => ({ ...current, scheduledDate: event.target.value }))
}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Completed Date"
type="date"
size="small"
value={form.completedDate}
onChange={(event) =>
setForm((current) => ({ ...current, completedDate: event.target.value }))
}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Description"
multiline
rows={3}
size="small"
className="sm:col-span-2"
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button
variant="contained"
onClick={handleSave}
disabled={updateDispatchMutation.isPending}
>
{updateDispatchMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button onClick={() => setEditing(false)}>Cancel</Button>
</Stack>
</>
) : (
<>
<DetailField label="Status" value={dispatch.status} />
<DetailField
label="NTE"
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
/>
<DetailField
label="Scheduled"
value={toDateInputValue(dispatch.scheduledDate) || "—"}
/>
<DetailField
label="Completed"
value={toDateInputValue(dispatch.completedDate) || "—"}
/>
<DetailField
label="Dispatched"
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
/>
<DetailField
label="Accepted"
value={
dispatch.acknowledgedAt
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
: dispatch.status === "Sent"
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
: "—"
}
highlight={
dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined
}
/>
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
{dispatch.description && (
<DetailField
label="Description"
value={dispatch.description}
className="sm:col-span-2"
/>
)}
{!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button
color="error"
variant="outlined"
onClick={() => {
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
return;
}
updateDispatchMutation.mutate(
{
id: dispatchId,
status: "Cancelled",
nteAmount: dispatch.nteAmount,
scheduledDate: toDateInputValue(dispatch.scheduledDate) || null,
completedDate: toDateInputValue(dispatch.completedDate) || null,
description: dispatch.description,
},
{ onSuccess: onUpdated },
);
}}
>
Cancel Dispatch
</Button>
<Button variant="contained" onClick={beginEditing}>
Edit
</Button>
</Stack>
)}
</>
)}
</Box>
{(dispatch.checklistItems.length > 0 || !isLocked) && (
<Box>
<Typography variant="subtitle1" className="mb-2 font-semibold">
Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/
{dispatch.checklistItems.length})
</Typography>
<Stack spacing={1}>
{dispatch.checklistItems.map((item) => (
<Stack key={String(item.id)} direction="row" spacing={1} alignItems="center">
<Checkbox
checked={item.isCompleted}
disabled={isLocked || updateChecklistMutation.isPending}
onChange={() =>
updateChecklistMutation.mutate({
itemId: item.id,
isCompleted: !item.isCompleted,
completedBy: userName,
})
}
/>
<Box className="flex-1">
<Typography variant="body2">{item.itemText}</Typography>
{item.isCompleted && (
<Typography variant="caption" color="text.secondary">
{item.completedBy}
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
</Typography>
)}
</Box>
</Stack>
))}
</Stack>
{!isLocked && (
<TextField
size="small"
value={newChecklistItem}
onChange={(event) => setNewChecklistItem(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newChecklistItem.trim()) {
event.preventDefault();
addChecklistMutation.mutate(newChecklistItem.trim(), {
onSuccess: () => setNewChecklistItem(""),
});
}
}}
placeholder="Add checklist item..."
fullWidth
className="mt-2"
/>
)}
</Box>
)}
<Box>
<Typography variant="subtitle1" className="mb-2 font-semibold">
Sign-offs
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
{(["Customer", "Vendor"] as const).map((type) => {
const signoff = dispatch.signoffs.find((item) => item.signoffType === type);
return (
<Box key={type} className="flex-1 rounded border border-border p-3">
<Typography variant="subtitle2">{type}</Typography>
{signoff ? (
<Box className="mt-2">
{signoff.signatureMethod === "drawn" && signoff.signature && (
<img
src={signoff.signature}
alt="Signature"
className="mb-2 max-h-20 max-w-full"
/>
)}
{signoff.signatureMethod === "typed" && (
<Typography variant="h6" className="italic">
{signoff.signature}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{signoff.name}
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
</Typography>
</Box>
) : (
<Button
size="small"
variant="outlined"
className="mt-2"
onClick={() => setSigCaptureType(type)}
disabled={isLocked}
>
{isLocked ? "Not signed" : "Collect Signature"}
</Button>
)}
</Box>
);
})}
</Stack>
{dispatch.signoffs.length >= 2 && (
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
)}
</Box>
<Box>
<Typography variant="subtitle1" className="mb-2 font-semibold">
NTE Uplift Requests
</Typography>
{uplifts.length === 0 ? (
<Typography color="text.secondary">No uplift requests.</Typography>
) : (
<Stack spacing={2}>
{uplifts.map((uplift) => {
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
return (
<Box key={String(uplift.id)} className="rounded border border-border p-3">
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Chip label={uplift.status} size="small" />
{uplift.requiredTier === 2 && (
<Typography variant="caption">Tier 2 · Manager approval</Typography>
)}
<Typography variant="body2">
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
{Number(uplift.requestedNTE).toFixed(2)} (+$
{Number(uplift.delta).toFixed(2)})
</Typography>
</Stack>
{uplift.vendorReason && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason}
</Typography>
)}
{(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography variant="body2" color="text.secondary" className="mt-1">
Requested by {uplift.requestedByVendorName || "vendor"}
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
</Typography>
)}
{uplift.status !== "Pending" &&
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
<Box className="mt-1">
{(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" color="text.secondary">
{uplift.status} by {uplift.decidedByName || "—"}
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
)}
{uplift.decisionNote && (
<Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote}
</Typography>
)}
</Box>
)}
{uplift.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
<Tooltip
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
>
<span>
<Button
size="small"
color="success"
variant="contained"
disabled={!canDecide}
onClick={() => handleApproveUplift(uplift.id, uplift.requestedNTE)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
>
<span>
<Button
size="small"
color="error"
variant="contained"
disabled={!canDecide}
onClick={() => handleDenyUplift(uplift.id)}
>
Deny
</Button>
</span>
</Tooltip>
</Stack>
)}
</Box>
);
})}
</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle1" className="mb-2 font-semibold">
Vendor Communication
</Typography>
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
{dispatch.comments.length === 0 ? (
<Typography color="text.secondary">No messages yet.</Typography>
) : (
<Stack spacing={2}>
{dispatch.comments.map((comment) => (
<Box key={String(comment.id)}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
)}
</Box>
<Box component="form" onSubmit={handleComment}>
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Message vendor..."
fullWidth
disabled={isLocked || addCommentMutation.isPending}
/>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
className="mt-2"
>
<FormControlLabel
control={
<Checkbox
checked={sendEmail}
onChange={(event) => setSendEmail(event.target.checked)}
disabled={isLocked}
/>
}
label="Send email"
/>
<Button
type="submit"
variant="contained"
disabled={isLocked || addCommentMutation.isPending || !commentText.trim()}
>
{addCommentMutation.isPending ? "Sending..." : "Send"}
</Button>
</Stack>
</Box>
</Box>
{dispatch.verifiedAt ? (
<Alert severity="success">
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
</Alert>
) : (
<Stack direction="row" justifyContent="space-between" alignItems="center">
{verifyState.missing.length > 0 && (
<Typography variant="body2" color="text.secondary">
Missing: {verifyState.missing.join(", ")}
</Typography>
)}
<Button
variant="contained"
color="success"
disabled={!verifyState.canVerify || verifyMutation.isPending}
onClick={() => verifyMutation.mutate(undefined, { onSuccess: onUpdated })}
>
Verify & Close
</Button>
</Stack>
)}
</DialogContent>
</Dialog>
{sigCaptureType && (
<SignatureCapture
title={`${sigCaptureType} Sign-off`}
onClose={() => setSigCaptureType(null)}
onSave={(signature, method, name) => {
addSignoffMutation.mutate(
{
signoffType: sigCaptureType,
name,
signature,
signatureMethod: method,
},
{ onSuccess: () => setSigCaptureType(null) },
);
}}
/>
)}
</>
);
}
function DetailField({
label,
value,
className,
highlight,
}: {
label: string;
value: string;
className?: string;
highlight?: "green" | "yellow" | "red" | "";
}) {
const colorClass =
highlight === "green"
? "text-green-600"
: highlight === "yellow"
? "text-yellow-600"
: highlight === "red"
? "text-red-600"
: undefined;
return (
<Box className={className}>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
<Typography variant="body2" className={colorClass}>
{value}
</Typography>
</Box>
);
}

View file

@ -0,0 +1,355 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Autocomplete,
Box,
Breadcrumbs,
Button,
CircularProgress,
Link,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
import {
workOrderFormSchema,
type WorkOrderFormSchemaValues,
} from "@/domain/work-orders/schemas/work-order-schema";
import {
createDefaultWorkOrderFormValues,
WORK_ORDER_PRIORITY_OPTIONS,
WORK_ORDER_STATUS_OPTIONS,
type LocationOption,
} from "@/domain/work-orders/types/work-order";
import {
useWorkOrderDetail,
useWorkOrderEmployeesDropdown,
useWorkOrderLocations,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useCreateWorkOrder,
useUpdateWorkOrder,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
function SectionTitle({ children }: { children: string }) {
return (
<Typography variant="subtitle2" className="mb-3 font-semibold uppercase tracking-wide">
{children}
</Typography>
);
}
export default function WorkOrderFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const createMutation = useCreateWorkOrder();
const updateMutation = useUpdateWorkOrder(id ?? "");
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<WorkOrderFormSchemaValues>({
resolver: zodResolver(workOrderFormSchema),
defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
});
const tradeValue = useWatch({ control, name: "trade" });
const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
parent: tradeValue || undefined,
enabled: Boolean(tradeValue),
});
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
useEffect(() => {
if (isEdit && detail) {
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
}
}, [detail, isEdit, reset]);
useEffect(() => {
if (!tradeValue) {
setValue("subTrade", "");
}
}, [tradeValue, setValue]);
const locationOptions = useMemo(() => locations, [locations]);
const isSaving = createMutation.isPending || updateMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
const onSubmit = (values: WorkOrderFormSchemaValues) => {
if (isEdit && id) {
updateMutation.mutate(values);
return;
}
createMutation.mutate(values);
};
const handleCancel = () => {
navigate(isEdit && id ? `/workorders/${id}` : "/workorders");
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
}
if (detailError) {
return (
<Box className="p-4">
<Alert severity="error">
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
</Alert>
</Box>
);
}
return (
<Box className="flex flex-col gap-4 p-4">
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
</Breadcrumbs>
<Typography variant="h5" component="h1">
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
</Typography>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("workOrderNumber")}
label="Customer WO"
placeholder="Customer work order number"
fullWidth
/>
<TextField
{...register("title")}
label="Title"
required
error={Boolean(errors.title)}
helperText={errors.title?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
<Controller
name="locationId"
control={control}
render={({ field }) => (
<Autocomplete
options={locationOptions}
loading={isLoadingLocations}
getOptionLabel={(option: LocationOption) => option.name}
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
renderInput={(params) => (
<TextField
{...params}
label="Location"
required
error={Boolean(errors.locationId)}
helperText={errors.locationId?.message}
/>
)}
className="flex-1"
/>
)}
/>
<TextField
{...register("source")}
label="Source"
placeholder="e.g., Amazon APM, Manual"
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("problem")} label="Problem" select fullWidth>
<MenuItem value="">Select Problem</MenuItem>
{problems.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField {...register("trade")} label="Trade" select fullWidth>
<MenuItem value="">Select Trade</MenuItem>
{trades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField
{...register("subTrade")}
label="Sub-Trade"
select
disabled={!tradeValue}
fullWidth
>
<MenuItem value="">
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
</MenuItem>
{subTrades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
</Stack>
</Box>
<Box>
<SectionTitle>Schedule & Priority</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("priority")}
label="Priority"
select
required
error={Boolean(errors.priority)}
helperText={errors.priority?.message}
fullWidth
>
<MenuItem value="">Select Priority</MenuItem>
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
<MenuItem key={priority} value={priority}>
{priority}
</MenuItem>
))}
</TextField>
<TextField {...register("status")} label="Status" select required fullWidth>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
<Controller
name="assignedTo"
control={control}
render={({ field }) => (
<Autocomplete
freeSolo
options={employees.map((employee) => employee.name)}
value={field.value}
onChange={(_event, value) => field.onChange(value ?? "")}
onInputChange={(_event, value) => field.onChange(value)}
renderInput={(params) => (
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
)}
className="flex-1"
/>
)}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
<TextField
{...register("createdDate")}
label="Created"
type="date"
required
error={Boolean(errors.createdDate)}
helperText={errors.createdDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("dueDate")}
label="Due Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("scheduledDate")}
label="Scheduled"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("completedDate")}
label="Completed"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Financial</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
inputProps={{ step: "0.01", min: 0 }}
fullWidth
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
{...register("tt")}
label="TT"
placeholder="Trouble ticket number"
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Box>
</Paper>
</Box>
);
}

View file

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

View file

@ -0,0 +1,310 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
import {
useWorkOrderUsers,
useWorkOrdersList,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
function formatDateTime(value: string): string {
if (!value) {
return "—";
}
const normalized = value.endsWith("Z") ? value : `${value}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return value;
}
const datePart = date.toLocaleDateString("en-CA");
const timePart = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
}
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const debouncedSearch = useDebounce(search, 300);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
sortBy,
sortDir,
status: statusFilter,
assignee: assigneeFilter,
}),
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
);
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
const { data: users = [] } = useWorkOrderUsers();
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
if (sortDir === "desc") {
setSortBy("");
}
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
const handleStatusFilter = (status: string) => {
setStatusFilter((current) => (current === status ? "" : status));
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">
Work Orders
</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..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
+ New Work Order
</Button>
</Stack>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Chip
label="All"
clickable
color={statusFilter === "" ? "primary" : "default"}
variant={statusFilter === "" ? "filled" : "outlined"}
onClick={() => {
setStatusFilter("");
setPage(0);
}}
/>
{STATUS_OPTIONS.map((status) => (
<Chip
key={status}
label={status}
clickable
color={statusFilter === status ? "primary" : "default"}
variant={statusFilter === status ? "filled" : "outlined"}
onClick={() => handleStatusFilter(status)}
/>
))}
<FormControl size="small" className="min-w-[180px]">
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
<Select
labelId="assignee-filter-label"
label="Assignee"
value={assigneeFilter}
onChange={(event) => {
setAssigneeFilter(event.target.value);
setPage(0);
}}
>
<MenuItem value="">All Assignees</MenuItem>
<MenuItem value="__unassigned">Unassigned</MenuItem>
{users.map((user) => (
<MenuItem key={String(user.id)} value={user.name}>
{user.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell
className="cursor-pointer whitespace-nowrap font-semibold"
onClick={() => handleSort("number")}
>
WO #{sortIndicator("number")}
</TableCell>
<TableCell
className="cursor-pointer font-semibold"
onClick={() => handleSort("title")}
>
Title{sortIndicator("title")}
</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Created</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Last Updated</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</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 work orders found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderListItem) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.number || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
<TableCell>{row.location}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.createdDate)}
</TableCell>
<TableCell>{row.assignedTo || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.lastUpdated)}
</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
size="small"
aria-label="View work order"
onClick={() => navigate(`/workorders/${row.id}`)}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit work order"
onClick={() => navigate(`/workorders/edit/${row.id}`)}
>
<EditOutlinedIcon 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]}
/>
</Box>
);
}

View file

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

21
src/app/404.tsx Normal file
View file

@ -0,0 +1,21 @@
import { Link } from "react-router-dom";
import { Box, Button, Typography } from "@mui/material";
export default function NotFoundPage() {
return (
<Box
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-(--color-bg-page) px-4"
role="main"
>
<Typography variant="h4" component="h1" className="font-display text-foreground">
Page not found
</Typography>
<Typography variant="body1" className="text-muted-foreground">
The page you are looking for does not exist or has been moved.
</Typography>
<Button component={Link} to="/" variant="contained">
Go to dashboard
</Button>
</Box>
);
}

2
src/app/_app.tsx Normal file
View file

@ -0,0 +1,2 @@
/** Generouted reserved filename — root shell for all routes. */
export { default } from "./_components/root-layout";

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