mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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:
parent
1632f1e656
commit
a0cf7b9ef0
479 changed files with 29821 additions and 46928 deletions
52
.cursor/rules/seahaven-admin.mdc
Normal file
52
.cursor/rules/seahaven-admin.mdc
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
description: SeaHaven Admin — IrisLoan architecture, selective rebuild conventions
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# SeaHaven Admin — IrisLoan conventions
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Vite SPA + React Router 7, TypeScript strict, Tailwind v4 + MUI
|
||||||
|
- HTTP: Ky with `credentials: "include"` (httpOnly cookies)
|
||||||
|
- Server state: TanStack Query; forms: React Hook Form + Zod
|
||||||
|
- **Do not use** in new code: Next.js, Redux, axios, `apiUtil.js`, `tokenUtility.js`
|
||||||
|
|
||||||
|
## Target structure (`src/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
api/ domain/<feature>/ app/ infra/query-key/ lib/ hooks/
|
||||||
|
components/ui|common|layout/ config/menu.ts providers/ main.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- `@/` alias for imports; new files in **kebab-case** (`use-debounce.ts`, `query-key.ts`)
|
||||||
|
- `pages/` is **reference only** for endpoints and business rules — delete after delivery in `domain/` + `app/`
|
||||||
|
|
||||||
|
## HTTP and auth
|
||||||
|
|
||||||
|
- Centralize paths in `api/api-paths.ts` and responses in `api/handle-api-response.ts`
|
||||||
|
- Auth via AuthProvider + cookies; do not create Redux slices for session
|
||||||
|
|
||||||
|
## State and data
|
||||||
|
|
||||||
|
- Cache and mutations: React Query + `infra/query-key/query-key.ts`
|
||||||
|
- Client defaults in `lib/query/query-client.ts`
|
||||||
|
|
||||||
|
## Forms and UI
|
||||||
|
|
||||||
|
- Zod schemas in `domain/<feature>/schemas/`; thin views in `app/`
|
||||||
|
- Utility Tailwind + MUI components; **do not** import `site.css` in new code
|
||||||
|
|
||||||
|
## Feature implementation flow
|
||||||
|
|
||||||
|
1. Consult legacy in `pages/<feature>/` (endpoints, fields, rules)
|
||||||
|
2. Create `domain/<feature>/` (schemas, hooks, use-cases)
|
||||||
|
3. Thin view in `app/` + route in `app/routes.tsx`
|
||||||
|
4. Delete `pages/<feature>/` when the new feature is ready
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
- Strict ESLint only on new architecture `.ts/.tsx`; legacy in `pages/` ignored
|
||||||
|
- Commits: Conventional Commits (`feat(scope): subject`)
|
||||||
|
|
||||||
|
Reference: `docs/ARCHITECTURE_PLAN.md`
|
||||||
3
.env
3
.env
|
|
@ -1,2 +1 @@
|
||||||
REACT_APP_API_URL=
|
VITE_API_URL=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Development API URL (uses proxy)
|
# Development API URL (uses proxy)
|
||||||
REACT_APP_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|
||||||
# Development API Target (for proxy)
|
# Development API Target (for proxy)
|
||||||
REACT_APP_API_TARGET=http://localhost:5141
|
VITE_API_TARGET=http://localhost:5141
|
||||||
|
|
|
||||||
15
.env.example
15
.env.example
|
|
@ -1,13 +1,12 @@
|
||||||
# API Configuration
|
# API Configuration
|
||||||
# Copy this file to .env and update with your actual values
|
# Copy this file to .env and update with your actual values
|
||||||
|
|
||||||
# Backend API Base URL (without /api suffix)
|
# Backend API base URL (Ky prefixUrl). API route paths do NOT include /api — it comes from here.
|
||||||
# For local development, this should point to your ASP.NET Core API
|
# Development: use /api (Vite proxies /api to VITE_API_TARGET)
|
||||||
#REACT_APP_API_URL=http://localhost:5000
|
#VITE_API_URL=/api
|
||||||
|
|
||||||
# For production, update to your production API URL
|
# Production: absolute URLs MUST include the /api path segment
|
||||||
REACT_APP_API_URL=https://api.seahavenindustries.com
|
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||||
|
|
||||||
# Note: The /api prefix is added in the service files
|
|
||||||
# Example: ${REACT_APP_API_URL}/api/Authentication/login
|
|
||||||
|
|
||||||
|
# Development proxy target (used by vite.config.ts)
|
||||||
|
#VITE_API_TARGET=http://localhost:5141
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
# Production API URL
|
# 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
141
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -8,8 +8,13 @@
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/playwright/.cache
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
/dist
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
|
@ -25,3 +30,9 @@ yarn-error.log*
|
||||||
db.txt
|
db.txt
|
||||||
seed-data.sql
|
seed-data.sql
|
||||||
/backend
|
/backend
|
||||||
|
|
||||||
|
# eslint
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
npx commitlint --edit $1
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
npx lint-staged
|
||||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
package-lock.json
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Legacy — format incrementally when touched (lint-staged)
|
||||||
|
src/pages/**
|
||||||
|
src/**/*.js
|
||||||
|
src/**/*.jsx
|
||||||
|
src/**/*.css
|
||||||
|
!src/styles/globals.css
|
||||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.mdc",
|
||||||
|
"options": {
|
||||||
|
"parser": "markdown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
92
README.md
92
README.md
|
|
@ -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.\
|
```bash
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
npm ci
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
The page will reload when you make changes.\
|
### Environment variables
|
||||||
You may also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `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.\
|
`VITE_API_URL` is the full API base prefix. Route paths in `API_PATHS` do **not** include `/api` — only this variable supplies it.
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `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.\
|
See [`.env.example`](.env.example) and [`.env.production`](.env.production) for reference values.
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
## Scripts
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
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/).
|
During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`).
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
|
||||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export default { extends: ["@commitlint/config-conventional"] };
|
||||||
41
config/api-url-contract.test.ts
Normal file
41
config/api-url-contract.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertAbsoluteApiUrlHasApiSegment } from "./api-url-contract";
|
||||||
|
|
||||||
|
describe("assertAbsoluteApiUrlHasApiSegment", () => {
|
||||||
|
it("allows relative /api", () => {
|
||||||
|
expect(() => assertAbsoluteApiUrlHasApiSegment("/api")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows empty or undefined", () => {
|
||||||
|
expect(() => assertAbsoluteApiUrlHasApiSegment(undefined)).not.toThrow();
|
||||||
|
expect(() => assertAbsoluteApiUrlHasApiSegment("")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows absolute URL ending with /api", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com/api"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects absolute URL without /api", () => {
|
||||||
|
expect(() => assertAbsoluteApiUrlHasApiSegment("https://api.seahavenindustries.com")).toThrow(
|
||||||
|
/must end with \/api/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(".env.example contract", () => {
|
||||||
|
it("documents an absolute VITE_API_URL that includes /api", () => {
|
||||||
|
const envExample = readFileSync(path.resolve(process.cwd(), ".env.example"), "utf8");
|
||||||
|
const match = envExample.match(/^VITE_API_URL=(.+)$/m);
|
||||||
|
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const value = match![1]!.trim();
|
||||||
|
|
||||||
|
if (value.startsWith("http")) {
|
||||||
|
expect(() => assertAbsoluteApiUrlHasApiSegment(value)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
21
config/api-url-contract.ts
Normal file
21
config/api-url-contract.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/** Absolute VITE_API_URL values must end with this segment (API_PATHS omit /api). */
|
||||||
|
export const ABSOLUTE_API_URL_SUFFIX = /\/api\/?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fails fast when an absolute VITE_API_URL omits the /api segment.
|
||||||
|
* Relative values (e.g. /api) are valid and skipped.
|
||||||
|
*/
|
||||||
|
export function assertAbsoluteApiUrlHasApiSegment(apiUrl: string | undefined): void {
|
||||||
|
const trimmed = apiUrl?.trim();
|
||||||
|
if (!trimmed?.startsWith("http")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ABSOLUTE_API_URL_SUFFIX.test(trimmed)) {
|
||||||
|
throw new Error(
|
||||||
|
`VITE_API_URL absolute URLs must end with /api (got: ${trimmed}). ` +
|
||||||
|
"API route paths do not include /api — set VITE_API_URL to the full API base, e.g. https://api.example.com/api. " +
|
||||||
|
"See README.md and .env.example.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
docs/ARCHITECTURE_PLAN.md
Normal file
102
docs/ARCHITECTURE_PLAN.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Architecture Plan — SeaHaven (Selective Rebuild)
|
||||||
|
|
||||||
|
> Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI
|
||||||
|
|
||||||
|
Mirror document of the Cursor plan. Strategy: **reuse knowledge, discard legacy code** — do not normalize/refactor the current project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategy in one sentence
|
||||||
|
|
||||||
|
Build `api/` + `domain/` + `app/` from scratch; use `pages/` only as **endpoint and business rule reference**; **delete** each legacy folder when the new feature is ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Matrix: Keep vs Discard
|
||||||
|
|
||||||
|
### Keep (extract logic → new code)
|
||||||
|
|
||||||
|
| Legacy | Keep | Destination |
|
||||||
|
| ------------------------------------- | -------------------------------- | ------------------------------------------------ |
|
||||||
|
| `lib/api/services.js` | ASP.NET paths + response parsing | `api/api-paths.ts`, `api/handle-api-response.ts` |
|
||||||
|
| `pages/*/api.js` | Real endpoints | `api/api-paths.ts` |
|
||||||
|
| `constants/queryKeys.js` | Entities | `infra/query-key/query-key.ts` |
|
||||||
|
| `hooks/useDebounce.js`, `useModal.js` | Hooks | `hooks/*.ts` |
|
||||||
|
| `hooks/api/usePMSchedules.js` | RQ pattern | Template `domain/*/use-cases/` |
|
||||||
|
| `lib/queryClient.js` | Cache defaults | `lib/query/query-client.ts` |
|
||||||
|
| `App.js`, `Sidebar.js` | Routes and menu | `app/routes.tsx`, `config/menu.ts` |
|
||||||
|
| `pages/workorders/` (list) | Business flow | Reference for POC |
|
||||||
|
|
||||||
|
### Discard (delete without refactoring)
|
||||||
|
|
||||||
|
- `apiUtil.js`, `services/api.js`, `lib/api/client.js`
|
||||||
|
- `tokenUtility.js`, `authService.js`, Redux (`app/store.js`, slices)
|
||||||
|
- `usePaginatedList.js`, entire `pages/` folder (after replacement)
|
||||||
|
- `Site.Layout.js`, `setupProxy.js`, `site.css`, per-feature CSS, Font Awesome
|
||||||
|
- Forms `*FormPage.js` (rewrite with RHF + Zod)
|
||||||
|
- `SharedTable.js`, `ActionBar.js` (recreate if adapting is costly)
|
||||||
|
|
||||||
|
### Defer (later)
|
||||||
|
|
||||||
|
- `pages/vendor-portal/` → `domain/vendor-portal/`
|
||||||
|
- `pages/calendar/` → new FullCalendar wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature priority
|
||||||
|
|
||||||
|
Full matrix (waves, dependencies, LOC, done criteria): **[`FEATURE_PRIORITIZATION.md`](FEATURE_PRIORITIZATION.md)** · TS config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
||||||
|
|
||||||
|
| Wave | Scope |
|
||||||
|
| ----------------- | --------------------------------------------------------------------------- |
|
||||||
|
| **0 POC** | auth → work-orders (list) |
|
||||||
|
| **1 must-have** | work-orders (full), dashboard, settings/dropdowns |
|
||||||
|
| **2** | accounts, locations, employees |
|
||||||
|
| **3 should-have** | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
|
||||||
|
| **4 later** | calendar, vendor-portal, pm-schedules, assets, reports/documents |
|
||||||
|
|
||||||
|
Stakeholder decision: **auth + work-orders first**; POC = auth + work-orders list (not pm-schedules).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/
|
||||||
|
├── domain/<feature>/
|
||||||
|
├── infra/query-key/
|
||||||
|
├── lib/
|
||||||
|
├── components/ui/ + common/
|
||||||
|
├── app/
|
||||||
|
├── config/menu.ts
|
||||||
|
├── hooks/
|
||||||
|
├── providers/
|
||||||
|
└── main.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| Phase | Goal |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------- |
|
||||||
|
| **0** | Complete foundation (Ky, routes, layout, AuthProvider, tooling) — Vite/TS/Tailwind ok |
|
||||||
|
| **1** | Ky + httpOnly auth cookies; delete legacy HTTP stacks |
|
||||||
|
| **2** | Rewrite must-have features; delete `pages/<feature>/` per delivery |
|
||||||
|
| **3** | UI shell + RHF+Zod forms |
|
||||||
|
| **4** | Remove `pages/`, Redux, legacy CSS; Docker + CI + Vitest |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. Complete foundation (`api/api.ts`, `routes.tsx`, AuthProvider, layout shell)
|
||||||
|
2. `api/api-paths.ts` (extract from `lib/api/services.js`)
|
||||||
|
3. POC: `domain/auth` + login → delete `pages/auth/`
|
||||||
|
4. POC: `domain/work-orders` (list) → delete list portion of `pages/workorders/`
|
||||||
|
5. Cookie contract with backend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See full plan with diagrams in `.cursor/plans/migração_arquitetura_irisloan_cf39e1a6.plan.md`.
|
||||||
137
docs/DESIGN_SYSTEM.md
Normal file
137
docs/DESIGN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# SeaHaven Design System
|
||||||
|
|
||||||
|
Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **MUI-only** — no Radix/shadcn; MUI is the sole component library
|
||||||
|
- **Tailwind v4** — semantic utility classes from CSS tokens
|
||||||
|
- **Fonts** — Montserrat (headings), DM Sans (body), JetBrains Mono (code) via `@fontsource`
|
||||||
|
|
||||||
|
## Token source of truth
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `src/styles/theme.css` | All `:root` CSS variables + `@theme inline` Tailwind aliases |
|
||||||
|
| `src/styles/fonts.css` | `@fontsource` imports |
|
||||||
|
| `src/styles/typography.css` | Base `html`, `body`, `h1–h6`, `.text-caption`, `.text-label` |
|
||||||
|
| `src/styles/motion.css` | `--duration-fast`, `--duration-normal`, `--ease-default` |
|
||||||
|
| `src/styles/globals.css` | Entry: imports all above + `@custom-variant dark` |
|
||||||
|
|
||||||
|
## Key tokens
|
||||||
|
|
||||||
|
### Brand & surfaces
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
| -------------------- | --------- | -------------------------- |
|
||||||
|
| `--primary` | `#1c75bc` | Buttons, links, focus ring |
|
||||||
|
| `--primary-hover` | `#155a92` | Hover states |
|
||||||
|
| `--foreground` | `#262262` | Primary text |
|
||||||
|
| `--background` | `#ffffff` | Cards, inputs |
|
||||||
|
| `--color-bg-page` | `#f9fafb` | Page background |
|
||||||
|
| `--border` | `#dfe3ea` | Dividers, outlines |
|
||||||
|
| `--muted-foreground` | `#58595b` | Secondary text |
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
| ----------------------------- | ------- |
|
||||||
|
| `--spacing-sidebar` | `220px` |
|
||||||
|
| `--spacing-sidebar-collapsed` | `56px` |
|
||||||
|
| `--spacing-topbar` | `64px` |
|
||||||
|
|
||||||
|
### Typography scale
|
||||||
|
|
||||||
|
| Token | Size |
|
||||||
|
| ---------------- | ---- |
|
||||||
|
| `--text-xs` | 11px |
|
||||||
|
| `--text-sm` | 12px |
|
||||||
|
| `--text-base-sm` | 13px |
|
||||||
|
| `--text-base` | 14px |
|
||||||
|
| `--text-md` | 16px |
|
||||||
|
| `--text-lg` | 18px |
|
||||||
|
| `--text-xl` | 22px |
|
||||||
|
| `--text-2xl` | 28px |
|
||||||
|
|
||||||
|
### Elevation
|
||||||
|
|
||||||
|
| Token | Usage |
|
||||||
|
| ------------- | ------------------- |
|
||||||
|
| `--shadow-sm` | Cards, subtle depth |
|
||||||
|
| `--shadow-md` | Dropdowns, panels |
|
||||||
|
| `--shadow-lg` | Dialogs, modals |
|
||||||
|
|
||||||
|
### Work order badges (immutable)
|
||||||
|
|
||||||
|
Type, status, and completion-doc tokens (`--type-*`, `--status-*`, `--doc-*`) must not be changed — see `src/components/domain/`.
|
||||||
|
|
||||||
|
## Typography hierarchy
|
||||||
|
|
||||||
|
| Element | Font | Weight | Size |
|
||||||
|
| --------------- | ---------- | ------ | -------------- |
|
||||||
|
| `h1` | Montserrat | 800 | 28px |
|
||||||
|
| `h2` | Montserrat | 700 | 22px |
|
||||||
|
| `h3` | Montserrat | 600 | 18px |
|
||||||
|
| `h4–h6` | Montserrat | 600 | 16–13px |
|
||||||
|
| Body | DM Sans | 400 | 14px |
|
||||||
|
| `.text-caption` | DM Sans | 400 | 12px |
|
||||||
|
| `.text-label` | DM Sans | 600 | 11px uppercase |
|
||||||
|
|
||||||
|
MUI typography variants in `src/lib/theme/mui-theme.ts` mirror this hierarchy.
|
||||||
|
|
||||||
|
## MUI + Tailwind usage
|
||||||
|
|
||||||
|
### Prefer themed MUI in forms and data tables
|
||||||
|
|
||||||
|
MUI components inherit Seahaven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`).
|
||||||
|
|
||||||
|
### Prefer semantic Tailwind in layout shells
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-background text-foreground border-border" />
|
||||||
|
<div className="text-muted-foreground" />
|
||||||
|
<div className="bg-primary text-primary-foreground" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compose classes with `cn()`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
<div className={cn("flex gap-2", isActive && "bg-sidebar-accent")} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared UI wrappers
|
||||||
|
|
||||||
|
| Component | Path | Pattern |
|
||||||
|
| ------------ | ------------------------------- | ---------------------------- |
|
||||||
|
| `Button` | `components/ui/button.tsx` | MUI Button + variant presets |
|
||||||
|
| `Badge` | `components/ui/badge.tsx` | MUI Chip |
|
||||||
|
| `PageHeader` | `components/ui/page-header.tsx` | PATTERN_001 |
|
||||||
|
| `EmptyState` | `components/ui/empty-state.tsx` | PATTERN_003 |
|
||||||
|
| `FormField` | `components/ui/form-field.tsx` | PATTERN_004 |
|
||||||
|
|
||||||
|
### Domain badges
|
||||||
|
|
||||||
|
| Component | Path |
|
||||||
|
| -------------------------------------- | ------------------------------------ |
|
||||||
|
| `TypeBadge` | `components/domain/type-badge.tsx` |
|
||||||
|
| `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` |
|
||||||
|
| `DocBadge` | `components/domain/doc-badge.tsx` |
|
||||||
|
|
||||||
|
## Legacy → Seahaven mapping
|
||||||
|
|
||||||
|
| Legacy | Seahaven | Consumption |
|
||||||
|
| ------------------------- | ------------------------------ | -------------------------------- |
|
||||||
|
| `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` |
|
||||||
|
| `#083a52` / `--blue-dark` | `--primary-hover` | Button hover |
|
||||||
|
| `#f4f6f7` / `--bg` | `--color-bg-page` | Body, page shell |
|
||||||
|
| `#d9dde0` / `--border` | `--border` | Dividers, inputs |
|
||||||
|
| `#fbfbfb` sidebar | `--color-sidebar-bg` | App sidebar |
|
||||||
|
| Segoe UI 13px | DM Sans 14px | Body typography |
|
||||||
|
| Bootstrap calendar colors | `--primary`, `--success`, etc. | `calendar-utils.ts` |
|
||||||
|
| `#1a3a5c` login header | `--color-header-bg-start` | Login branding strip |
|
||||||
|
|
||||||
|
## Dark mode
|
||||||
|
|
||||||
|
`@custom-variant dark (&:is(.dark *))` is configured in `globals.css`. Full dark token set is a future enhancement; light mode is the current default.
|
||||||
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
88
docs/DESIGN_SYSTEM_MIGRATION.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Design System Migration Log
|
||||||
|
|
||||||
|
Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens. Strategy: **MUI-only**.
|
||||||
|
|
||||||
|
## Phase 1 — Foundation
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| --------------------------- | ---------- | --------------------------------------------------------------------- |
|
||||||
|
| `src/styles/theme.css` | Created | SoT: 131+ Seahaven tokens + layout/typography/elevation extensions |
|
||||||
|
| `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` |
|
||||||
|
| `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles |
|
||||||
|
| `src/styles/motion.css` | Created | Duration and easing tokens |
|
||||||
|
| `src/styles/globals.css` | Refactored | Import chain + `@custom-variant dark`; removed legacy `@theme` tokens |
|
||||||
|
| `src/lib/utils.ts` | Created | `cn()` with clsx + tailwind-merge |
|
||||||
|
| `src/lib/theme/css-vars.ts` | Created | `getCssVar()` helper for MUI bridge |
|
||||||
|
| `package.json` | Modified | Added `@fontsource/*`, `clsx`, `tailwind-merge` |
|
||||||
|
|
||||||
|
## Phase 2 — MUI Bridge
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| ---------------------------- | -------- | --------------------------------------------------------------- |
|
||||||
|
| `src/lib/theme/mui-theme.ts` | Expanded | Full palette, typography, shape, shadows, 8 component overrides |
|
||||||
|
|
||||||
|
## Phase 3 — App Shell
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| ----------------------------------------- | -------- | -------------------------------------------------- |
|
||||||
|
| `src/components/layout/app-sidebar.tsx` | Created | MUI Drawer + sidebar tokens; replaces `Sidebar.js` |
|
||||||
|
| `src/components/layout/app-topbar.tsx` | Created | MUI AppBar + header gradient; replaces `Topbar.js` |
|
||||||
|
| `src/app/(protected)/_layout.tsx` | Modified | Uses new layout components |
|
||||||
|
| `src/main.tsx` | Modified | Removed `index.css` import |
|
||||||
|
| `src/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Seahaven CSS vars |
|
||||||
|
| `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell |
|
||||||
|
| `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` |
|
||||||
|
| `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` |
|
||||||
|
| `src/styles/FormPage.css` | Deleted | Unused (no imports in codebase) |
|
||||||
|
|
||||||
|
## Phase 4 — Shared UI
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| ------------------------------------------ | -------- | -------------------------------------------- |
|
||||||
|
| `src/components/ui/button.tsx` | Created | MUI Button wrapper |
|
||||||
|
| `src/components/ui/badge.tsx` | Created | MUI Chip wrapper |
|
||||||
|
| `src/components/ui/page-header.tsx` | Created | PATTERN_001 |
|
||||||
|
| `src/components/ui/empty-state.tsx` | Created | PATTERN_003 |
|
||||||
|
| `src/components/ui/form-field.tsx` | Created | PATTERN_004 |
|
||||||
|
| `src/components/domain/type-badge.tsx` | Created | WO type tokens |
|
||||||
|
| `src/components/domain/status-badge.tsx` | Created | WO status tokens + API aliases |
|
||||||
|
| `src/components/domain/doc-badge.tsx` | Created | Completion doc tokens (MUI icons) |
|
||||||
|
| `src/app/(protected)/workorders/index.tsx` | Modified | `WorkOrderStatusBadge` replaces generic Chip |
|
||||||
|
|
||||||
|
## Phase 5 — Page refactor
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| ---------------------------------------------------------------------- | -------- | ------------------------------------------ |
|
||||||
|
| `src/app/(auth)/login.tsx` | Modified | `#1a3a5c` → `var(--color-header-bg-start)` |
|
||||||
|
| `src/app/(protected)/dashboard.tsx` | Modified | `border-gray-*` → `border-border` |
|
||||||
|
| `src/app/(protected)/index.tsx` | Modified | Same as dashboard |
|
||||||
|
| `src/app/(protected)/settings/dropdowns.tsx` | Modified | Semantic sidebar + active states |
|
||||||
|
| `src/app/(protected)/settings/task-templates.tsx` | Modified | Semantic borders/text |
|
||||||
|
| `src/components/common/settings-nav.tsx` | Modified | `text-primary`, `border-border` |
|
||||||
|
| `src/components/common/calendar/calendar-utils.ts` | Modified | Bootstrap hex → Seahaven CSS vars |
|
||||||
|
| `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes |
|
||||||
|
| `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes |
|
||||||
|
| `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars |
|
||||||
|
| `src/app/(protected)/workorders/_components/dispatch-create-modal.tsx` | Modified | Semantic borders/hover |
|
||||||
|
| `src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx` | Modified | Semantic borders |
|
||||||
|
| `src/app/(protected)/workorders/[id].tsx` | Modified | Semantic borders |
|
||||||
|
| `src/app/(protected)/vendor-pos/[id].tsx` | Modified | Semantic borders |
|
||||||
|
|
||||||
|
## Phase 6 — Docs & validation
|
||||||
|
|
||||||
|
| File | Action | Reason |
|
||||||
|
| --------------------------------- | ------- | ------------------------------- |
|
||||||
|
| `docs/DESIGN_SYSTEM.md` | Created | Token reference and usage guide |
|
||||||
|
| `docs/DESIGN_SYSTEM_MIGRATION.md` | Created | This file |
|
||||||
|
|
||||||
|
## Visual impact summary
|
||||||
|
|
||||||
|
- Primary color: `#0c4f6f` → `#1c75bc` (expected, documented)
|
||||||
|
- Body font: Segoe UI 13px → DM Sans 14px
|
||||||
|
- Sidebar: `#fbfbfb` → `#f6f8fb` with Seahaven active/hover states
|
||||||
|
- Header: flat blue gradient → Seahaven multi-stop gradient
|
||||||
|
|
||||||
|
## Not migrated (intentional)
|
||||||
|
|
||||||
|
- CRUD list pages using default MUI theming — inherit via `mui-theme.ts` without JSX changes
|
||||||
|
- `src/app/v/_components/signature-pad.tsx` — vendor portal isolated; minor `#111` stroke remains
|
||||||
160
docs/FEATURE_PRIORITIZATION.md
Normal file
160
docs/FEATURE_PRIORITIZATION.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Feature Prioritization Matrix — SeaHaven
|
||||||
|
|
||||||
|
> Source of truth for legacy → `domain/` + `app/` migration order.
|
||||||
|
> Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stakeholder decisions
|
||||||
|
|
||||||
|
| Decision | Detail |
|
||||||
|
| ------------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
|
| Top priority | **`auth`** and **`work-orders`** |
|
||||||
|
| End-to-end POC | **`auth` + work-orders (list)** — validate Ky, React Query, layout shell and AuthProvider |
|
||||||
|
| Other features | Flexible order, guided by technical dependencies (waves below) |
|
||||||
|
| POC **does not** include | `pm-schedules` (deferred to Wave 4) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full matrix
|
||||||
|
|
||||||
|
| Order | Feature | Tier | Wave | Legacy folder | Routes | LOC ~ | Dependencies |
|
||||||
|
| ----- | ------------------------- | ----------- | ---- | ----------------------------------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------- |
|
||||||
|
| 1 | auth | POC | 0 | `pages/auth/` | `/login` | 513 | — |
|
||||||
|
| 2 | work-orders (list) | POC | 0 | `pages/workorders/` (list) | `/workorders` | 4.3k\* | auth |
|
||||||
|
| 3 | work-orders (form + view) | must-have | 1 | `pages/workorders/` | `/workorders/new`, `/workorders/:id`, `/workorders/edit/:id` | 4.3k\* | auth, work-orders (list), settings/dropdowns, accounts, locations, employees |
|
||||||
|
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
|
||||||
|
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
|
||||||
|
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
|
||||||
|
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
|
||||||
|
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
|
||||||
|
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
|
||||||
|
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
|
||||||
|
| 11 | uplifts | should-have | 3 | `pages/uplifts/` | `/uplifts` | 317 | auth, work-orders (full) |
|
||||||
|
| 12 | follow-ups | should-have | 3 | `pages/followup/` | `/followups`, `/followups/new`, `/followups/edit/:id` | 1.3k | auth, employees, accounts, locations |
|
||||||
|
| 13 | contacts | should-have | 3 | `pages/contacts/` | `/contacts`, `/contacts/new`, `/contacts/edit/:id` | 1.1k | auth |
|
||||||
|
| 14 | settings/task-templates | should-have | 3 | `pages/settings/` (TaskTemplates) | `/settings/task-templates` | 866\* | auth, work-orders (full) |
|
||||||
|
| 15 | calendar | later | 4 | `pages/calendar/` | `/calendar`, `/calendar/new`, `/calendar/edit/:id` | 1.9k | auth |
|
||||||
|
| 16 | vendor-portal | later | 4 | `pages/vendor-portal/` | `/v/:token`, `/v/:token/dashboard`, `/v/:token/pos`, `/v/:token/dispatch/:id` | 1.4k | auth, vendors |
|
||||||
|
| 17 | pm-schedules | later | 4 | `pages/PmSchedule/` | `/pmschedules`, `/pmschedules/new`, `/pmschedules/edit/:id` | 1.1k | auth |
|
||||||
|
| 18 | assets | later | 4 | `pages/assets/` | `/assets`, `/assets/new`, `/assets/edit/:id` | 638 | auth, accounts |
|
||||||
|
| 19 | reports / documents | later | 4 | — (no implementation) | `/reports`, `/documents` (menu only) | — | auth |
|
||||||
|
|
||||||
|
\* LOC for the entire folder; POC scope uses only the list submodule.
|
||||||
|
|
||||||
|
### Legacy complexity (reference)
|
||||||
|
|
||||||
|
| Feature | Complexity | Notes |
|
||||||
|
| ------------- | ---------- | --------------------------------------------------------- |
|
||||||
|
| auth | Low | Replace Redux/`tokenUtility` with AuthProvider + cookies |
|
||||||
|
| dashboard | Medium | Real KPIs via `GET /Dashboard/Stats` |
|
||||||
|
| work-orders | **High** | Monolithic view; dispatch, checklist, signoff |
|
||||||
|
| contacts | High | Legacy form **stub** (save TODO) — rewrite with RHF + Zod |
|
||||||
|
| employees | High | Large form |
|
||||||
|
| vendor-portal | High | URL token auth + signature |
|
||||||
|
| calendar | High | FullCalendar wrapper |
|
||||||
|
| pm-schedules | Medium | Legacy API bugs (`PmSchedule/Create` vs `Save`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency graph
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph wave0 [Wave0_POC]
|
||||||
|
auth[auth]
|
||||||
|
woList[work_orders_list]
|
||||||
|
end
|
||||||
|
subgraph wave1 [Wave1_MustHave]
|
||||||
|
woFull[work_orders_full]
|
||||||
|
dash[dashboard]
|
||||||
|
dropdowns[settings_dropdowns]
|
||||||
|
end
|
||||||
|
subgraph wave2 [Wave2_Dependencies]
|
||||||
|
accounts[accounts]
|
||||||
|
locations[locations]
|
||||||
|
employees[employees]
|
||||||
|
end
|
||||||
|
subgraph wave3 [Wave3_ShouldHave]
|
||||||
|
vendors[vendors]
|
||||||
|
vendorPos[vendor_pos]
|
||||||
|
uplifts[uplifts]
|
||||||
|
followups[follow_ups]
|
||||||
|
contacts[contacts]
|
||||||
|
settingsTpl[task_templates]
|
||||||
|
end
|
||||||
|
subgraph wave4 [Wave4_Later]
|
||||||
|
calendar[calendar]
|
||||||
|
vendorPortal[vendor_portal]
|
||||||
|
pmSched[pm_schedules]
|
||||||
|
assets[assets]
|
||||||
|
reports[reports_documents]
|
||||||
|
end
|
||||||
|
auth --> woList
|
||||||
|
woList --> woFull
|
||||||
|
dropdowns --> woFull
|
||||||
|
accounts --> woFull
|
||||||
|
locations --> woFull
|
||||||
|
employees --> woFull
|
||||||
|
woFull --> vendors
|
||||||
|
vendors --> vendorPortal
|
||||||
|
woFull --> vendorPos
|
||||||
|
woFull --> uplifts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Waves (summary)
|
||||||
|
|
||||||
|
| Wave | Tier | Features |
|
||||||
|
| ----- | ----------- | --------------------------------------------------------------------------- |
|
||||||
|
| **0** | POC | auth, work-orders (list) |
|
||||||
|
| **1** | must-have | work-orders (full), dashboard, settings/dropdowns |
|
||||||
|
| **2** | must-have | accounts, locations, employees |
|
||||||
|
| **3** | should-have | vendors, vendor-pos, uplifts, follow-ups, contacts, settings/task-templates |
|
||||||
|
| **4** | later | calendar, vendor-portal, pm-schedules, assets, reports/documents |
|
||||||
|
|
||||||
|
> `accounts` and `locations` can progress in parallel with the WO view if the list POC uses data already available in the backend; the order above is the minimum sequence for the full WO form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy gaps and cleanup
|
||||||
|
|
||||||
|
| Item | Location | Migration action |
|
||||||
|
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| Duplicate dashboard | `pages/dashboard/` (mock prototype) | **Not routed** — ignore; use only `pages/Dashboard.js` as reference |
|
||||||
|
| Orphan login | `pages/user/api.js` | Delete in Phase 1 (Ky + auth); duplicates `pages/auth/` flow |
|
||||||
|
| Ghost menu | `Sidebar.js` → Reports `/reports`, Documents `/documents` | No routes or pages — implement from scratch (Wave 4) or remove from menu in `config/menu.ts` |
|
||||||
|
| Incomplete contacts form | `pages/contacts/form/` | Do not port stub; rewrite with RHF + Zod in Wave 3 |
|
||||||
|
| PM schedules API | `pages/PmSchedule/` | Document Create vs Save inconsistency; fix in rewrite |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Done" criteria per feature
|
||||||
|
|
||||||
|
A feature is **done** when all items below are met:
|
||||||
|
|
||||||
|
1. **`domain/<feature>/`** — Zod schemas, hooks/use-cases, types; no imports from `pages/`, Redux, axios or `apiUtil`
|
||||||
|
2. **`app/`** — thin views (list, form, view as scoped) registered in `app/routes.tsx`
|
||||||
|
3. **`api/api-paths.ts`** — feature endpoints centralized; responses via `handle-api-response.ts`
|
||||||
|
4. **`infra/query-key/query-key.ts`** — feature query keys
|
||||||
|
5. **Protected route** — navigation via `config/menu.ts` (when applicable to the wave)
|
||||||
|
6. **Delete legacy** — `pages/<feature>/` folder (or equivalent files) removed after validation
|
||||||
|
7. **No regression** — main flow tested manually or with Vitest (when available)
|
||||||
|
|
||||||
|
### Done criteria for special scope
|
||||||
|
|
||||||
|
| Scope | Additional criterion |
|
||||||
|
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||||
|
| **POC auth** | Login/logout with httpOnly cookies; `AuthProvider` replaces Redux + `tokenUtility` |
|
||||||
|
| **POC work-orders list** | Paginated/filtered list with Ky + RQ; no form/view in this delivery |
|
||||||
|
| **work-orders full** | Form + view (dispatch, checklist, signoff) functional |
|
||||||
|
| **settings/dropdowns** | Problem/Trade/SubTrade dropdowns available for WO form |
|
||||||
|
| **reports/documents** | Explicit decision: implement feature or remove menu links |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Architecture plan: [`ARCHITECTURE_PLAN.md`](ARCHITECTURE_PLAN.md)
|
||||||
|
- IrisLoan conventions: `.cursor/rules/seahaven-admin.mdc`
|
||||||
|
- Legacy routes: `src/App.js`
|
||||||
|
- Legacy menu: `src/components/Sidebar.js`
|
||||||
|
|
@ -112,16 +112,19 @@ src/
|
||||||
### 1. State Management: Redux vs React Query
|
### 1. State Management: Redux vs React Query
|
||||||
|
|
||||||
**Use Redux for:**
|
**Use Redux for:**
|
||||||
|
|
||||||
- ✅ User authentication (login/logout)
|
- ✅ User authentication (login/logout)
|
||||||
- ✅ App-wide settings (theme, sidebar state)
|
- ✅ App-wide settings (theme, sidebar state)
|
||||||
- ✅ Data that needs to be shared everywhere
|
- ✅ Data that needs to be shared everywhere
|
||||||
|
|
||||||
**Use React Query for:**
|
**Use React Query for:**
|
||||||
|
|
||||||
- ✅ API data (lists, details)
|
- ✅ API data (lists, details)
|
||||||
- ✅ Any server data
|
- ✅ Any server data
|
||||||
- ✅ Automatic caching and refetching
|
- ✅ Automatic caching and refetching
|
||||||
|
|
||||||
**Use useState for:**
|
**Use useState for:**
|
||||||
|
|
||||||
- ✅ Local component state
|
- ✅ Local component state
|
||||||
- ✅ Form inputs
|
- ✅ Form inputs
|
||||||
- ✅ UI toggles (dropdowns, tabs)
|
- ✅ UI toggles (dropdowns, tabs)
|
||||||
|
|
@ -129,18 +132,21 @@ src/
|
||||||
### 2. CSS Modules (Built-in - No Dependencies!)
|
### 2. CSS Modules (Built-in - No Dependencies!)
|
||||||
|
|
||||||
**Old Way** (Global CSS):
|
**Old Way** (Global CSS):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import './Component.css';
|
import './Component.css';
|
||||||
<div className="container">
|
<div className="container">
|
||||||
```
|
```
|
||||||
|
|
||||||
**New Way** (CSS Modules):
|
**New Way** (CSS Modules):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import styles from './Component.module.css';
|
import styles from './Component.module.css';
|
||||||
<div className={styles.container}> // Automatically scoped!
|
<div className={styles.container}> // Automatically scoped!
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
|
|
||||||
- No naming conflicts
|
- No naming conflicts
|
||||||
- Better tree-shaking
|
- Better tree-shaking
|
||||||
- No extra build tools needed
|
- No extra build tools needed
|
||||||
|
|
@ -150,13 +156,13 @@ import styles from './Component.module.css';
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// ❌ Bad
|
// ❌ Bad
|
||||||
queryKey: ['pmschedules']
|
queryKey: ["pmschedules"];
|
||||||
setTimeout(fn, 600)
|
setTimeout(fn, 600);
|
||||||
|
|
||||||
// ✅ Good
|
// ✅ Good
|
||||||
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from '../constants';
|
import { PM_SCHEDULES_LIST, DEBOUNCE_SEARCH } from "../constants";
|
||||||
queryKey: [PM_SCHEDULES_LIST]
|
queryKey: [PM_SCHEDULES_LIST];
|
||||||
setTimeout(fn, DEBOUNCE_SEARCH)
|
setTimeout(fn, DEBOUNCE_SEARCH);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -164,9 +170,10 @@ setTimeout(fn, DEBOUNCE_SEARCH)
|
||||||
## 💻 Code Examples
|
## 💻 Code Examples
|
||||||
|
|
||||||
### Example 1: Loading Data with React Query
|
### Example 1: Loading Data with React Query
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { usePMSchedules } from '../hooks/api/usePMSchedules';
|
import { usePMSchedules } from "../hooks/api/usePMSchedules";
|
||||||
import { useDebounce, DEBOUNCE_SEARCH } from '../constants';
|
import { useDebounce, DEBOUNCE_SEARCH } from "../constants";
|
||||||
|
|
||||||
function PMScheduleList() {
|
function PMScheduleList() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -174,7 +181,7 @@ function PMScheduleList() {
|
||||||
|
|
||||||
const { data, isLoading, error } = usePMSchedules({
|
const { data, isLoading, error } = usePMSchedules({
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
page: 1
|
page: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>;
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
|
@ -184,7 +191,9 @@ function PMScheduleList() {
|
||||||
<div>
|
<div>
|
||||||
<input value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<ul>
|
<ul>
|
||||||
{data.items.map(item => <li key={item.id}>{item.name}</li>)}
|
{data.items.map((item) => (
|
||||||
|
<li key={item.id}>{item.name}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -192,8 +201,9 @@ function PMScheduleList() {
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example 2: Using Redux Auth
|
### Example 2: Using Redux Auth
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const { user, isAuthenticated, login, logout } = useAuth();
|
const { user, isAuthenticated, login, logout } = useAuth();
|
||||||
|
|
@ -204,16 +214,16 @@ function MyComponent() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
Welcome {user.name}!
|
Welcome {user.name}!<button onClick={logout}>Logout</button>
|
||||||
<button onClick={logout}>Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example 3: Using Modals
|
### Example 3: Using Modals
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { useModal } from '../hooks/useModal';
|
import { useModal } from "../hooks/useModal";
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const deleteModal = useModal();
|
const deleteModal = useModal();
|
||||||
|
|
@ -223,9 +233,7 @@ function MyComponent() {
|
||||||
<button onClick={() => deleteModal.open(item)}>Delete</button>
|
<button onClick={() => deleteModal.open(item)}>Delete</button>
|
||||||
|
|
||||||
{deleteModal.isOpen && (
|
{deleteModal.isOpen && (
|
||||||
<Modal onClose={deleteModal.close}>
|
<Modal onClose={deleteModal.close}>Delete {deleteModal.data.name}?</Modal>
|
||||||
Delete {deleteModal.data.name}?
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -233,8 +241,9 @@ function MyComponent() {
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example 4: CSS Modules
|
### Example 4: CSS Modules
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import styles from './LoginPage.module.css';
|
import styles from "./LoginPage.module.css";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -260,6 +269,7 @@ function LoginPage() {
|
||||||
## 🔧 Common Patterns
|
## 🔧 Common Patterns
|
||||||
|
|
||||||
### Pattern 1: Fetch and Display Data
|
### Pattern 1: Fetch and Display Data
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { data, isLoading, error } = useDataHook(params);
|
const { data, isLoading, error } = useDataHook(params);
|
||||||
|
|
||||||
|
|
@ -270,6 +280,7 @@ return <Display data={data} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 2: Delete with Confirmation
|
### Pattern 2: Delete with Confirmation
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const deleteMutation = useDeleteHook();
|
const deleteMutation = useDeleteHook();
|
||||||
const deleteModal = useModal();
|
const deleteModal = useModal();
|
||||||
|
|
@ -286,6 +297,7 @@ const handleDelete = async () => {
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 3: Search with Debounce
|
### Pattern 3: Search with Debounce
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
|
|
@ -300,42 +312,48 @@ const { data } = useDataHook({ search: debouncedSearch });
|
||||||
## 📚 Constants Reference
|
## 📚 Constants Reference
|
||||||
|
|
||||||
### API Constants
|
### API Constants
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
API_URL // Backend URL
|
API_URL; // Backend URL
|
||||||
API_ERROR_MESSAGE // Default error message
|
API_ERROR_MESSAGE; // Default error message
|
||||||
API_SUCCESS_MESSAGE // Success message
|
API_SUCCESS_MESSAGE; // Success message
|
||||||
```
|
```
|
||||||
|
|
||||||
### Timing Constants
|
### Timing Constants
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
DEBOUNCE_SEARCH // 600ms
|
DEBOUNCE_SEARCH; // 600ms
|
||||||
CACHE_TIME_MEDIUM // 5 minutes
|
CACHE_TIME_MEDIUM; // 5 minutes
|
||||||
STALE_TIME_MEDIUM // 5 minutes
|
STALE_TIME_MEDIUM; // 5 minutes
|
||||||
```
|
```
|
||||||
|
|
||||||
### View Modes
|
### View Modes
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
VIEW_MODE_LIST // 'list'
|
VIEW_MODE_LIST; // 'list'
|
||||||
VIEW_MODE_CARD // 'card'
|
VIEW_MODE_CARD; // 'card'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Status Types
|
### Status Types
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
STATUS_OPEN // 'Open'
|
STATUS_OPEN; // 'Open'
|
||||||
STATUS_COMPLETED // 'Completed'
|
STATUS_COMPLETED; // 'Completed'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Query Keys
|
### Query Keys
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
PM_SCHEDULES_LIST // 'pmSchedulesList'
|
PM_SCHEDULES_LIST; // 'pmSchedulesList'
|
||||||
WORK_ORDERS_LIST // 'workOrdersList'
|
WORK_ORDERS_LIST; // 'workOrdersList'
|
||||||
EMPLOYEES_DROPDOWN // 'employeesDropdown'
|
EMPLOYEES_DROPDOWN; // 'employeesDropdown'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage Keys
|
### Storage Keys
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
STORAGE_KEY_TOKEN // 'token'
|
STORAGE_KEY_TOKEN; // 'token'
|
||||||
STORAGE_KEY_THEME // 'theme'
|
STORAGE_KEY_THEME; // 'theme'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -343,16 +361,19 @@ STORAGE_KEY_THEME // 'theme'
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### 1. Run Development Server
|
### 1. Run Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Build for Production
|
### 2. Build for Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Deploy to S3
|
### 3. Deploy to S3
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws s3 sync build/ s3://shoc-ui-app --acl public-read
|
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
|
### Converting a Page to New Architecture
|
||||||
|
|
||||||
**Step 1: Use React Query Hook**
|
**Step 1: Use React Query Hook**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Old
|
// Old
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchData().then(setData).finally(() => setLoading(false));
|
fetchData()
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// New
|
// New
|
||||||
|
|
@ -378,6 +402,7 @@ const { data, isLoading } = usePMSchedules({ page: 1 });
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Use Constants**
|
**Step 2: Use Constants**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Old
|
// Old
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -387,11 +412,12 @@ useEffect(() => {
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// New
|
// New
|
||||||
import { DEBOUNCE_SEARCH } from '../constants';
|
import { DEBOUNCE_SEARCH } from "../constants";
|
||||||
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3: Convert CSS to Modules**
|
**Step 3: Convert CSS to Modules**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 1. Rename: Component.css → Component.module.css
|
// 1. Rename: Component.css → Component.module.css
|
||||||
// 2. Update import: import styles from './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
|
## ✅ What's Been Refactored
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
- ✅ **LoginPage** - Uses Redux for auth
|
- ✅ **LoginPage** - Uses Redux for auth
|
||||||
- ✅ **PM Schedules List** - Uses React Query, constants, CSS Modules
|
- ✅ **PM Schedules List** - Uses React Query, constants, CSS Modules
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
- ✅ **Topbar** - Uses Redux for user state
|
- ✅ **Topbar** - Uses Redux for user state
|
||||||
- ✅ **DeleteModal** - Reusable component
|
- ✅ **DeleteModal** - Reusable component
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
- ✅ **useAuth** - Redux integration for login/logout
|
- ✅ **useAuth** - Redux integration for login/logout
|
||||||
- ✅ **useModal** - Reusable modal state
|
- ✅ **useModal** - Reusable modal state
|
||||||
- ✅ **useDebounce** - Search debouncing
|
- ✅ **useDebounce** - Search debouncing
|
||||||
|
|
@ -433,6 +462,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
## 📖 Quick Reference
|
## 📖 Quick Reference
|
||||||
|
|
||||||
### File to Check for Examples
|
### File to Check for Examples
|
||||||
|
|
||||||
- `src/pages/PmSchedule/list/List.js` - Fully refactored list page
|
- `src/pages/PmSchedule/list/List.js` - Fully refactored list page
|
||||||
- `src/pages/auth/LoginPage.js` - Redux auth + CSS Modules
|
- `src/pages/auth/LoginPage.js` - Redux auth + CSS Modules
|
||||||
- `src/hooks/api/usePMSchedules.js` - React Query hook pattern
|
- `src/hooks/api/usePMSchedules.js` - React Query hook pattern
|
||||||
|
|
@ -463,15 +493,19 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
## 🛠️ Troubleshooting
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
### Issue: Constants not found
|
### Issue: Constants not found
|
||||||
|
|
||||||
**Solution:** Make sure you're importing from `'../constants'` (folder) not `'../constants.js'` (old file)
|
**Solution:** Make sure you're importing from `'../constants'` (folder) not `'../constants.js'` (old file)
|
||||||
|
|
||||||
### Issue: CSS not scoped
|
### Issue: CSS not scoped
|
||||||
|
|
||||||
**Solution:** File must be named `.module.css` and imported as `import styles from`
|
**Solution:** File must be named `.module.css` and imported as `import styles from`
|
||||||
|
|
||||||
### Issue: Redux state not updating
|
### Issue: Redux state not updating
|
||||||
|
|
||||||
**Solution:** Make sure you're using the hooks (`useAuth`, `useSelector`) not direct access
|
**Solution:** Make sure you're using the hooks (`useAuth`, `useSelector`) not direct access
|
||||||
|
|
||||||
### Issue: React Query not caching
|
### Issue: React Query not caching
|
||||||
|
|
||||||
**Solution:** Check that query keys use constants and are consistent
|
**Solution:** Check that query keys use constants and are consistent
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -479,6 +513,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
## 🎉 Summary
|
## 🎉 Summary
|
||||||
|
|
||||||
### What We Built:
|
### What We Built:
|
||||||
|
|
||||||
- ✅ Modern React architecture
|
- ✅ Modern React architecture
|
||||||
- ✅ Redux Toolkit for global state
|
- ✅ Redux Toolkit for global state
|
||||||
- ✅ React Query for server state
|
- ✅ React Query for server state
|
||||||
|
|
@ -487,6 +522,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
- ✅ Custom hooks for reusable logic
|
- ✅ Custom hooks for reusable logic
|
||||||
|
|
||||||
### Benefits:
|
### Benefits:
|
||||||
|
|
||||||
- 📉 70% less boilerplate code
|
- 📉 70% less boilerplate code
|
||||||
- 🚀 Automatic caching and refetching
|
- 🚀 Automatic caching and refetching
|
||||||
- 🎨 No CSS naming conflicts
|
- 🎨 No CSS naming conflicts
|
||||||
|
|
@ -495,6 +531,7 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
- ⚡ Better performance
|
- ⚡ Better performance
|
||||||
|
|
||||||
### Next Steps:
|
### Next Steps:
|
||||||
|
|
||||||
1. Refactor remaining pages using PM Schedules as template
|
1. Refactor remaining pages using PM Schedules as template
|
||||||
2. Convert more CSS to CSS Modules
|
2. Convert more CSS to CSS Modules
|
||||||
3. Add more API hooks as needed
|
3. Add more API hooks as needed
|
||||||
|
|
@ -504,5 +541,4 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH);
|
||||||
|
|
||||||
**Welcome to the modern SeaHaven UI! 🎊**
|
**Welcome to the modern SeaHaven UI! 🎊**
|
||||||
|
|
||||||
*Last updated: 2026*
|
_Last updated: 2026_
|
||||||
|
|
||||||
104
e2e/auth/login.spec.ts
Normal file
104
e2e/auth/login.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "test-jwt-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "user@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Test User",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockDashboardStats(page: Page) {
|
||||||
|
await page.route("**/api/Dashboard/Stats", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
openWorkOrders: 0,
|
||||||
|
pendingApprovals: 0,
|
||||||
|
scheduledPMs: 0,
|
||||||
|
activeAssets: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Login page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.removeItem("auth");
|
||||||
|
});
|
||||||
|
await mockDashboardStats(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads login page with form fields", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
|
||||||
|
await expect(page.locator("#login-email")).toBeVisible();
|
||||||
|
await expect(page.locator("#login-password")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables submit until email and password are valid", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
const submitButton = page.getByRole("button", { name: "Sign In" });
|
||||||
|
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
await page.locator("#login-email").fill("not-an-email");
|
||||||
|
await page.locator("#login-password").fill("secret123");
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error message for invalid credentials", async ({ page }) => {
|
||||||
|
await page.route("**/api/Authentication/Login", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "Invalid credentials" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||||
|
await page.locator("#login-password").fill("wrong-password");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Invalid email or password. Please try again.")).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs in successfully and navigates to dashboard", async ({ page }) => {
|
||||||
|
await page.route("**/api/Authentication/Login", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ data: TEST_USER }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await page.locator("#login-email").fill("user@seahavenind.com");
|
||||||
|
await page.locator("#login-password").fill("secret123");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible();
|
||||||
|
|
||||||
|
const storedAuth = await page.evaluate(() => localStorage.getItem("auth"));
|
||||||
|
expect(storedAuth).toContain(TEST_USER.token);
|
||||||
|
expect(storedAuth).toContain(TEST_USER.fullname);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
eslint.config.js
Normal file
62
eslint.config.js
Normal 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
28
index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Seahaven - Officetrax" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||||
|
integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw=="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<title>Seahaven - Officetrax</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30198
package-lock.json
generated
30198
package-lock.json
generated
File diff suppressed because it is too large
Load diff
104
package.json
104
package.json
|
|
@ -2,55 +2,103 @@
|
||||||
"name": "seahaven-new-app",
|
"name": "seahaven-new-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@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/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^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/icons-material": "^7.3.1",
|
||||||
"@mui/material": "^7.3.1",
|
"@mui/material": "^7.3.1",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-query-devtools": "^5.100.10",
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.6.4",
|
"@testing-library/jest-dom": "^6.6.4",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"axios": "^1.16.0",
|
"clsx": "^2.1.1",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"ky": "^1.7.5",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.78.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-redux": "^9.2.0",
|
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"react-toastify": "^11.0.5",
|
"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": {
|
"devDependencies": {
|
||||||
"start": "react-scripts start",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"build": "react-scripts build",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"test": "react-scripts test",
|
"@eslint/js": "^10.0.1",
|
||||||
"eject": "react-scripts eject"
|
"@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": {
|
"engines": {
|
||||||
"extends": [
|
"node": ">=20"
|
||||||
"react-app",
|
|
||||||
"react-app/jest"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
playwright.config.ts
Normal file
26
playwright.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
|
||||||
179
src/App.js
179
src/App.js
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
@ -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
45
src/api/api-error.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
||||||
|
if (status === 401) {
|
||||||
|
return "You are not authorized to access this page.";
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return "You do not have permission to perform this action.";
|
||||||
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
return "The requested resource was not found.";
|
||||||
|
}
|
||||||
|
if (status === 500) {
|
||||||
|
return "A server error occurred. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
const message = record.message ?? record.Message ?? record.error;
|
||||||
|
if (typeof message === "string" && message.length > 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "An error occurred";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||||
|
}
|
||||||
154
src/api/api-paths.ts
Normal file
154
src/api/api-paths.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
export const API_PATHS = {
|
||||||
|
authentication: {
|
||||||
|
login: "Authentication/Login",
|
||||||
|
changePassword: "Authentication/ChangePassword",
|
||||||
|
forgetPassword: "Authentication/ForgetPassword",
|
||||||
|
verificationCode: "Authentication/VerificationCode",
|
||||||
|
resetPassword: "Authentication/ResetPassword",
|
||||||
|
},
|
||||||
|
workOrder: {
|
||||||
|
getList: "WorkOrder/GetWorkOrderList",
|
||||||
|
getById: "WorkOrder/GetWorkorderById",
|
||||||
|
create: "WorkOrder/Create",
|
||||||
|
update: "WorkOrder/Update",
|
||||||
|
delete: "WorkOrder/DeleteWorkOrder",
|
||||||
|
addWorkorder: "WorkOrder/AddWorkorder",
|
||||||
|
editWorkorder: "WorkOrder/EditWorkorder",
|
||||||
|
deleteWorkorder: "WorkOrder/DeleteWorkorder",
|
||||||
|
addCommentJson: "WorkOrder/AddCommentJson",
|
||||||
|
changeAssignment: "WorkOrder/ChangeAssignment",
|
||||||
|
updateChecklistItem: "WorkOrder/UpdateChecklistItem",
|
||||||
|
addChecklistItem: "WorkOrder/AddChecklistItem",
|
||||||
|
verifyDispatch: "WorkOrder/VerifyDispatch",
|
||||||
|
addDispatchSignoff: "WorkOrder/AddDispatchSignoff",
|
||||||
|
getDispatch: "WorkOrder/GetDispatch",
|
||||||
|
updateDispatch: "WorkOrder/UpdateDispatch",
|
||||||
|
addDispatchComment: "WorkOrder/AddDispatchComment",
|
||||||
|
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
||||||
|
changeStatus: "WorkOrder/ChangeStatus",
|
||||||
|
},
|
||||||
|
pmSchedule: {
|
||||||
|
getList: "PMSchedule/GetList",
|
||||||
|
getById: "PMSchedule/GetById",
|
||||||
|
save: "PMSchedule/Save",
|
||||||
|
delete: "PMSchedule/Delete",
|
||||||
|
},
|
||||||
|
followUp: {
|
||||||
|
getList: "FollowUp/GetFollowUpList",
|
||||||
|
getById: "FollowUp/GetById",
|
||||||
|
create: "FollowUp/Create",
|
||||||
|
update: "FollowUp/Update",
|
||||||
|
delete: "FollowUp/Delete",
|
||||||
|
complete: "FollowUp/Complete",
|
||||||
|
cancel: "FollowUp/Cancel",
|
||||||
|
save: "FollowUp/Save",
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
getList: "Employee/GetEmployeeList",
|
||||||
|
getById: "Employee/GetById",
|
||||||
|
create: "Employee/Create",
|
||||||
|
update: "Employee/Update",
|
||||||
|
delete: "Employee/Delete",
|
||||||
|
createEmployee: "Employee/CreateEmployee",
|
||||||
|
updateEmployee: "Employee/UpdateEmployee",
|
||||||
|
deleteEmployee: "Employee/DeleteEmployee",
|
||||||
|
addEmployees: "Employee/AddEmployees",
|
||||||
|
editEmployees: "Employee/EditEmployees",
|
||||||
|
deleteEmployees: "Employee/DeleteEmployees",
|
||||||
|
dropdown: "Employee/Dropdown",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
getList: "Contact/GetContactList",
|
||||||
|
getContacts: "Contact/GetContacts",
|
||||||
|
create: "Contact/Create",
|
||||||
|
update: "Contact/Update",
|
||||||
|
delete: "Contact/Delete",
|
||||||
|
addContacts: "Contact/AddContacts",
|
||||||
|
editContacts: "Contact/EditContacts",
|
||||||
|
deleteContacts: "Contact/DeleteContacts",
|
||||||
|
dropdown: "Contact/Dropdown",
|
||||||
|
getLocations: "Contact/GetLocations",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
getList: "Location/GetLocationList",
|
||||||
|
getById: "Location/GetById",
|
||||||
|
create: "Location/Create",
|
||||||
|
update: "Location/Update",
|
||||||
|
delete: "Location/Delete",
|
||||||
|
deleteLocation: "Location/DeleteLocation",
|
||||||
|
dropdown: "Location/Dropdown",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
getList: "Account/GetAccountList",
|
||||||
|
create: "Account/Create",
|
||||||
|
update: "Account/Update",
|
||||||
|
delete: "Account/Delete",
|
||||||
|
addAccount: "Account/AddAccount",
|
||||||
|
editAccount: "Account/EditAccount",
|
||||||
|
deleteAccount: "Account/DeleteAccount",
|
||||||
|
addAccounts: "Account/AddAccounts",
|
||||||
|
editAccounts: "Account/EditAccounts",
|
||||||
|
deleteAccounts: "Account/DeleteAccounts",
|
||||||
|
dropdown: "Account/Dropdown",
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
getList: "Asset/GetAssetList",
|
||||||
|
getById: "Asset/GetById",
|
||||||
|
getAssetById: "Asset/GetAssetById",
|
||||||
|
create: "Asset/Create",
|
||||||
|
update: "Asset/Update",
|
||||||
|
delete: "Asset/Delete",
|
||||||
|
deleteAsset: "Asset/DeleteAsset",
|
||||||
|
addAsset: "Asset/AddAsset",
|
||||||
|
editAsset: "Asset/EditAsset",
|
||||||
|
},
|
||||||
|
vendor: {
|
||||||
|
getList: "Vendor/GetVendorList",
|
||||||
|
getById: "Vendor/GetById",
|
||||||
|
create: "Vendor/Create",
|
||||||
|
update: "Vendor/Update",
|
||||||
|
delete: "Vendor/Delete",
|
||||||
|
dropdown: "Vendor/Dropdown",
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
|
getEventList: "Calendar/GetEventList",
|
||||||
|
getEventById: "Calendar/GetEventById",
|
||||||
|
addEvent: "Calendar/AddEvent",
|
||||||
|
editEvent: "Calendar/EditEvent",
|
||||||
|
deleteEvent: "Calendar/DeleteEvent",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
stats: "Dashboard/Stats",
|
||||||
|
},
|
||||||
|
dropdownOptions: {
|
||||||
|
byCategory: "DropdownOptions/ByCategory",
|
||||||
|
create: "DropdownOptions/Create",
|
||||||
|
update: "DropdownOptions/Update",
|
||||||
|
delete: "DropdownOptions",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
list: "User",
|
||||||
|
},
|
||||||
|
taskListTemplate: {
|
||||||
|
list: "TaskListTemplate",
|
||||||
|
},
|
||||||
|
vendorPortal: {
|
||||||
|
base: "vendor-portal",
|
||||||
|
session: "/session",
|
||||||
|
dispatches: "/dispatches",
|
||||||
|
},
|
||||||
|
rest: {
|
||||||
|
employees: "employees",
|
||||||
|
contacts: "contacts",
|
||||||
|
accounts: "accounts",
|
||||||
|
locations: "locations",
|
||||||
|
workOrders: "workorders",
|
||||||
|
pmSchedules: "pmschedules",
|
||||||
|
followups: "followups",
|
||||||
|
vendors: "vendors",
|
||||||
|
taskListTemplates: "tasklisttemplates",
|
||||||
|
uplifts: "uplifts",
|
||||||
|
dispatches: "dispatches",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
113
src/api/api.ts
Normal file
113
src/api/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import queryClient from "@/lib/query/query-client";
|
||||||
|
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||||
|
|
||||||
|
function clearSessionAndRedirectToLogin(): void {
|
||||||
|
clearAuth();
|
||||||
|
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthLoginRequest(request: Request): boolean {
|
||||||
|
return request.url.includes(API_PATHS.authentication.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api: KyInstance = ky.create({
|
||||||
|
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeRequest: [
|
||||||
|
(request) => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
request.headers.set("Authorization", token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
beforeError: [
|
||||||
|
async (error: HTTPError) => {
|
||||||
|
const { response } = error;
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
error.message = "No response from server. Please check your connection.";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = await createApiErrorFromResponse(response);
|
||||||
|
error.message = apiError.message;
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterResponse: [
|
||||||
|
async (request, _options, response) => {
|
||||||
|
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionAndRedirectToLogin();
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||||
|
return api.get(url, options).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||||
|
return api.post(url, { ...options, json: body }).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||||
|
return api.put(url, { ...options, json: body }).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||||
|
return api.delete(url, options).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPostNoContent(
|
||||||
|
url: string,
|
||||||
|
body?: unknown,
|
||||||
|
options?: Options,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await api.post(url, { ...options, json: body });
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||||
|
const response = await api.delete(url, options);
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { mapHttpStatusToMessage };
|
||||||
73
src/api/handle-api-response.ts
Normal file
73
src/api/handle-api-response.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleApiResponse<T>(data: unknown): T {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(data)) {
|
||||||
|
const nested = data.data ?? data.Data ?? data.value;
|
||||||
|
if (nested !== undefined) {
|
||||||
|
return nested as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePaginatedResponse<T>(
|
||||||
|
data: unknown,
|
||||||
|
params?: { page?: number; pageSize?: number },
|
||||||
|
): PaginatedResult<T> {
|
||||||
|
const page = params?.page ?? 1;
|
||||||
|
const pageSize = params?.pageSize ?? 12;
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return {
|
||||||
|
items: data as T[],
|
||||||
|
totalCount: data.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(data)) {
|
||||||
|
const nested = data.data ?? data.Data ?? data.value ?? data;
|
||||||
|
const items = Array.isArray(nested) ? (nested as T[]) : [];
|
||||||
|
|
||||||
|
const totalCount =
|
||||||
|
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
|
||||||
|
(typeof data.TotalCount === "number" ? data.TotalCount : undefined) ??
|
||||||
|
(typeof data.Count === "number" ? data.Count : undefined) ??
|
||||||
|
items.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
page:
|
||||||
|
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
|
||||||
|
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
|
||||||
|
page,
|
||||||
|
pageSize:
|
||||||
|
(typeof data.pageSize === "number" ? data.pageSize : undefined) ??
|
||||||
|
(typeof data.PageSize === "number" ? data.PageSize : undefined) ??
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
totalCount: 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
5
src/app-root.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Routes } from "@/app-routes";
|
||||||
|
|
||||||
|
export function AppRoot() {
|
||||||
|
return <Routes />;
|
||||||
|
}
|
||||||
1
src/app-routes.tsx
Normal file
1
src/app-routes.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Routes } from "@/routing";
|
||||||
21
src/app/(auth)/_components/auth-card-header.tsx
Normal file
21
src/app/(auth)/_components/auth-card-header.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(className)} {...props}>
|
||||||
|
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AuthPageShellProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
|
cardClassName?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthPageShell({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
cardClassName,
|
||||||
|
...props
|
||||||
|
}: AuthPageShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-screen w-full items-center justify-center bg-(--color-bg-page) px-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-sm rounded-(--radius-lg) border border-border bg-card p-10! shadow-sm",
|
||||||
|
cardClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/(auth)/_components/login-form.tsx
Normal file
94
src/app/(auth)/_components/login-form.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type {
|
||||||
|
FieldErrors,
|
||||||
|
UseFormHandleSubmit,
|
||||||
|
UseFormRegister,
|
||||||
|
UseFormWatch,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FormErrorMessage } from "@/components/ui/form-error-message";
|
||||||
|
import { FormField } from "@/components/ui/form-field";
|
||||||
|
import { PasswordField } from "@/components/ui/password-field";
|
||||||
|
import { TextField } from "@/components/ui/text-field";
|
||||||
|
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
||||||
|
|
||||||
|
export type LoginFormProps = {
|
||||||
|
register: UseFormRegister<LoginFormValues>;
|
||||||
|
handleSubmit: UseFormHandleSubmit<LoginFormValues>;
|
||||||
|
watch: UseFormWatch<LoginFormValues>;
|
||||||
|
errors: FieldErrors<LoginFormValues>;
|
||||||
|
onSubmit: (values: LoginFormValues) => void;
|
||||||
|
isLoggingIn: boolean;
|
||||||
|
loginError: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
isLoggingIn,
|
||||||
|
loginError,
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const [username, password] = watch(["username", "password"]);
|
||||||
|
const isSubmitDisabled = !loginSchema.safeParse({ username, password }).success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
htmlFor="login-email"
|
||||||
|
error={Boolean(errors.username)}
|
||||||
|
helperText={errors.username?.message}
|
||||||
|
appearance="auth"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
{...register("username")}
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@seahavenind.com"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
error={Boolean(errors.username)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
htmlFor="login-password"
|
||||||
|
error={Boolean(errors.password)}
|
||||||
|
helperText={errors.password?.message}
|
||||||
|
appearance="auth"
|
||||||
|
>
|
||||||
|
<PasswordField
|
||||||
|
{...register("password")}
|
||||||
|
id="login-password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
error={Boolean(errors.password)}
|
||||||
|
/>
|
||||||
|
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
loading={isLoggingIn}
|
||||||
|
loadingText="Signing in…"
|
||||||
|
disabled={isSubmitDisabled}
|
||||||
|
className="mt-1!"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/(auth)/_layout.tsx
Normal file
11
src/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { GuestRoute } from "@/components/auth/guest-route";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<GuestRoute>
|
||||||
|
<Outlet />
|
||||||
|
</GuestRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/(auth)/login.tsx
Normal file
45
src/app/(auth)/login.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
||||||
|
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||||
|
import { LoginForm } from "@/app/(auth)/_components/login-form";
|
||||||
|
import { BrandLockup } from "@/components/common/brand-lockup";
|
||||||
|
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isLoggingIn, loginError } = useAuthContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormValues>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell>
|
||||||
|
<BrandLockup className="mb-8" />
|
||||||
|
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
|
||||||
|
<LoginForm
|
||||||
|
register={register}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
onSubmit={login}
|
||||||
|
isLoggingIn={isLoggingIn}
|
||||||
|
loginError={loginError}
|
||||||
|
/>
|
||||||
|
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
|
||||||
|
Having trouble? Contact your administrator.
|
||||||
|
</p>
|
||||||
|
</AuthPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(protected)/_layout.tsx
Normal file
36
src/app/(protected)/_layout.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
|
import { getSidebarWidth } from "@/config/menu";
|
||||||
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
|
|
||||||
|
export default function AdminShellLayout() {
|
||||||
|
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||||
|
const sidebarWidth = getSidebarWidth(navCollapsed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||||
|
<AppSidebar collapsed={navCollapsed} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
|
>
|
||||||
|
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
className="flex-1 overflow-auto p-2.5"
|
||||||
|
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/app/(protected)/accounts/_components/account-form.tsx
Normal file
182
src/app/(protected)/accounts/_components/account-form.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { accountSchema, type AccountFormValues } from "@/domain/accounts/schemas/account-schema";
|
||||||
|
import { useAccountDetail } from "@/domain/accounts/use-cases/use-account-detail";
|
||||||
|
import { useCreateAccount } from "@/domain/accounts/use-cases/use-create-account";
|
||||||
|
import { useUpdateAccount } from "@/domain/accounts/use-cases/use-update-account";
|
||||||
|
|
||||||
|
const defaultValues: AccountFormValues = {
|
||||||
|
AccountName: "",
|
||||||
|
AccountNumber: "",
|
||||||
|
Industry: "",
|
||||||
|
Website: "",
|
||||||
|
Notes: "",
|
||||||
|
Address1: "",
|
||||||
|
Address2: "",
|
||||||
|
City: "",
|
||||||
|
State: "",
|
||||||
|
Zip: "",
|
||||||
|
Country: "US",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: account, isLoading, error } = useAccountDetail(id);
|
||||||
|
const createAccount = useCreateAccount();
|
||||||
|
const updateAccount = useUpdateAccount();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<AccountFormValues>({
|
||||||
|
resolver: zodResolver(accountSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account && isEdit) {
|
||||||
|
reset({
|
||||||
|
AccountName: account.AccountName ?? "",
|
||||||
|
AccountNumber: account.AccountNumber ?? "",
|
||||||
|
Industry: account.Industry ?? "",
|
||||||
|
Website: account.Website ?? "",
|
||||||
|
Notes: account.Notes ?? "",
|
||||||
|
Address1: account.Address1 ?? "",
|
||||||
|
Address2: account.Address2 ?? "",
|
||||||
|
City: account.City ?? "",
|
||||||
|
State: account.State ?? "",
|
||||||
|
Zip: account.Zip ?? "",
|
||||||
|
Country: account.Country ?? "US",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [account, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createAccount.isPending || updateAccount.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: AccountFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateAccount.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createAccount.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{isEdit ? "Edit Account" : "Create an Account"}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="text" onClick={() => navigate("/accounts")}>
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load account"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
General Details
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("AccountName")}
|
||||||
|
label="Account Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.AccountName)}
|
||||||
|
helperText={errors.AccountName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField {...register("AccountNumber")} label="Account Number" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("Industry")} label="Industry" fullWidth />
|
||||||
|
<TextField {...register("Website")} label="Website" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<TextField {...register("Notes")} label="Notes" fullWidth multiline minRows={2} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Address
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("Address1")} label="Address 1" fullWidth />
|
||||||
|
<TextField {...register("Address2")} label="Address 2" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("City")} label="City" fullWidth />
|
||||||
|
<TextField {...register("State")} label="State / Province" fullWidth />
|
||||||
|
<TextField {...register("Zip")} label="Zip/Postal Code" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
name="Country"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="country-label">Country</InputLabel>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
labelId="country-label"
|
||||||
|
label="Country"
|
||||||
|
value={field.value ?? "US"}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Select Country</MenuItem>
|
||||||
|
<MenuItem value="US">United States</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/accounts")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update Account" : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
1
src/app/(protected)/accounts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/account-form";
|
||||||
221
src/app/(protected)/accounts/index.tsx
Normal file
221
src/app/(protected)/accounts/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
|
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||||
|
import { useDeleteAccount } from "@/domain/accounts/use-cases/use-delete-account";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function AccountsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AccountListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteAccount = useDeleteAccount();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useAccountsList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAccount.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Accounts
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search accounts..."
|
||||||
|
className="min-w-[220px]"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/accounts/new")}>
|
||||||
|
+ New Account
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load accounts"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Number</TableCell>
|
||||||
|
<TableCell className="font-semibold">Industry</TableCell>
|
||||||
|
<TableCell className="font-semibold">Website</TableCell>
|
||||||
|
<TableCell className="font-semibold">Primary Contact</TableCell>
|
||||||
|
<TableCell className="font-semibold">Phone</TableCell>
|
||||||
|
<TableCell className="font-semibold">Owner</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No accounts found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/accounts/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.number || "—"}</TableCell>
|
||||||
|
<TableCell>{row.industry || "—"}</TableCell>
|
||||||
|
<TableCell>{row.website || "—"}</TableCell>
|
||||||
|
<TableCell>{row.primaryContact || "—"}</TableCell>
|
||||||
|
<TableCell>{row.phoneNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.owner || "—"}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit account"
|
||||||
|
onClick={() => navigate(`/accounts/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete account"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Account</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAccount.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteAccount.isPending}
|
||||||
|
>
|
||||||
|
{deleteAccount.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/accounts/new.tsx
Normal file
1
src/app/(protected)/accounts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/account-form";
|
||||||
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { assetSchema, type AssetFormValues } from "@/domain/assets/schemas/asset-schema";
|
||||||
|
import { useAssetDetail } from "@/domain/assets/use-cases/use-asset-detail";
|
||||||
|
import { useAssetFormOptions } from "@/domain/assets/use-cases/use-asset-form-options";
|
||||||
|
import { useCreateAsset } from "@/domain/assets/use-cases/use-create-asset";
|
||||||
|
import { useUpdateAsset } from "@/domain/assets/use-cases/use-update-asset";
|
||||||
|
|
||||||
|
const defaultValues: AssetFormValues = {
|
||||||
|
assetCode: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
accountId: null,
|
||||||
|
locationId: null,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AssetFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: asset, isLoading, error } = useAssetDetail(id);
|
||||||
|
const { accounts, sites, isLoading: optionsLoading, error: optionsError } = useAssetFormOptions();
|
||||||
|
const createAsset = useCreateAsset();
|
||||||
|
const updateAsset = useUpdateAsset();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<AssetFormValues>({
|
||||||
|
resolver: zodResolver(assetSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (asset && isEdit) {
|
||||||
|
reset({
|
||||||
|
assetCode: asset.assetCode,
|
||||||
|
name: asset.name,
|
||||||
|
description: asset.description,
|
||||||
|
accountId: asset.accountId,
|
||||||
|
locationId: asset.locationId,
|
||||||
|
isActive: asset.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [asset, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createAsset.isPending || updateAsset.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: AssetFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateAsset.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createAsset.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((isEdit && isLoading) || optionsLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
|
||||||
|
|
||||||
|
{(error || optionsError) && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: optionsError instanceof Error
|
||||||
|
? optionsError.message
|
||||||
|
: "Failed to load asset"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Asset Code"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.assetCode)}
|
||||||
|
helperText={errors.assetCode?.message}
|
||||||
|
{...register("assetCode")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Account</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Account"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
field.onChange(value === "" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>None</em>
|
||||||
|
</MenuItem>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<MenuItem key={String(account.id)} value={account.id}>
|
||||||
|
{account.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="locationId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Location</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
field.onChange(value === "" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>None</em>
|
||||||
|
</MenuItem>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<MenuItem key={String(site.id)} value={site.id}>
|
||||||
|
{site.title ? `${site.title} - ${site.name}` : site.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isActive"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/asset-form";
|
||||||
223
src/app/(protected)/assets/index.tsx
Normal file
223
src/app/(protected)/assets/index.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { AssetListItem } from "@/domain/assets/types/asset";
|
||||||
|
import { useAssetsList } from "@/domain/assets/use-cases/use-assets-list";
|
||||||
|
import { useDeleteAsset } from "@/domain/assets/use-cases/use-delete-asset";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function AssetsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AssetListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteAsset = useDeleteAsset();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useAssetsList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteAsset.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Assets
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search assets..."
|
||||||
|
className="min-w-sidebar"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/assets/new")}>
|
||||||
|
+ New Asset
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load assets"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Asset Code</TableCell>
|
||||||
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Description</TableCell>
|
||||||
|
<TableCell className="font-semibold">Account</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No assets found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>{row.assetCode || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/assets/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.description || "—"}</TableCell>
|
||||||
|
<TableCell>{row.accountName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.locationName || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
|
color={row.isActive ? "success" : "default"}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit asset"
|
||||||
|
onClick={() => navigate(`/assets/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete asset"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Asset</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAsset.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteAsset.isPending}
|
||||||
|
>
|
||||||
|
{deleteAsset.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/assets/new.tsx
Normal file
1
src/app/(protected)/assets/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/asset-form";
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Alert, Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
export function CalendarUnavailablePanel() {
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl">
|
||||||
|
<Alert severity="warning" className="mb-4">
|
||||||
|
Calendário indisponível — a API de calendário ainda não está implementada no backend.
|
||||||
|
</Alert>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
As rotas permanecem reservadas para quando o CalendarController for disponibilizado.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarUnavailablePage() {
|
||||||
|
return (
|
||||||
|
<Box className="p-4">
|
||||||
|
<CalendarUnavailablePanel />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
src/app/(protected)/calendar/_components/event-form.tsx
Normal file
279
src/app/(protected)/calendar/_components/event-form.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
CALENDAR_REMINDER_OPTIONS,
|
||||||
|
CALENDAR_TIME_OPTIONS,
|
||||||
|
} from "@/components/common/calendar/calendar-utils";
|
||||||
|
import {
|
||||||
|
calendarEventSchema,
|
||||||
|
type CalendarEventFormValues,
|
||||||
|
} from "@/domain/calendar/schemas/calendar-event-schema";
|
||||||
|
import { useCalendarEventDetail } from "@/domain/calendar/use-cases/use-calendar-event-detail";
|
||||||
|
import { useCreateCalendarEvent } from "@/domain/calendar/use-cases/use-create-calendar-event";
|
||||||
|
import { useUpdateCalendarEvent } from "@/domain/calendar/use-cases/use-update-calendar-event";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
const COLORS = ["primary", "success", "info", "warning", "danger"] as const;
|
||||||
|
|
||||||
|
const defaultValues: CalendarEventFormValues = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
location: "",
|
||||||
|
recordId: null,
|
||||||
|
recordName: "",
|
||||||
|
recordTargetType: "",
|
||||||
|
reminderMinutes: 15,
|
||||||
|
eventColor: "primary",
|
||||||
|
startDate: new Date().toISOString().split("T")[0],
|
||||||
|
startTime: "08:00 AM",
|
||||||
|
endDate: new Date().toISOString().split("T")[0],
|
||||||
|
endTime: "08:30 AM",
|
||||||
|
allDay: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: event, isLoading, error } = useCalendarEventDetail(id);
|
||||||
|
const createEvent = useCreateCalendarEvent();
|
||||||
|
const updateEvent = useUpdateCalendarEvent();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CalendarEventFormValues>({
|
||||||
|
resolver: zodResolver(calendarEventSchema),
|
||||||
|
defaultValues: {
|
||||||
|
...defaultValues,
|
||||||
|
startDate: searchParams.get("date") ?? defaultValues.startDate,
|
||||||
|
endDate: searchParams.get("date") ?? defaultValues.endDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDay = useWatch({ control, name: "allDay" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (event && isEdit) {
|
||||||
|
reset({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
location: event.location,
|
||||||
|
recordId: event.recordId,
|
||||||
|
recordName: event.recordName,
|
||||||
|
recordTargetType: event.recordTargetType,
|
||||||
|
reminderMinutes: event.reminderMinutes,
|
||||||
|
eventColor: event.eventColor,
|
||||||
|
startDate: toDateInputValue(event.startDate),
|
||||||
|
startTime: event.startTime || "08:00 AM",
|
||||||
|
endDate: toDateInputValue(event.endDate),
|
||||||
|
endTime: event.endTime || "08:30 AM",
|
||||||
|
allDay: event.allDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [event, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createEvent.isPending || updateEvent.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: CalendarEventFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateEvent.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createEvent.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
|
||||||
|
|
||||||
|
{error && <Alert severity="error">Failed to load event</Alert>}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
error={Boolean(errors.title)}
|
||||||
|
helperText={errors.title?.message}
|
||||||
|
{...register("title")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Location"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ maxLength: 150 }}
|
||||||
|
{...register("location")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="reminderMinutes"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Remind Attendees</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Remind Attendees"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(event) => field.onChange(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{CALENDAR_REMINDER_OPTIONS.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="eventColor"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Calendar Color</InputLabel>
|
||||||
|
<Select label="Calendar Color" value={field.value} onChange={field.onChange}>
|
||||||
|
{COLORS.map((color) => (
|
||||||
|
<MenuItem key={color} value={color}>
|
||||||
|
{color}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!allDay && (
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Start Date"
|
||||||
|
type="date"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
error={Boolean(errors.startDate)}
|
||||||
|
helperText={errors.startDate?.message}
|
||||||
|
{...register("startDate")}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="startTime"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Start Time</InputLabel>
|
||||||
|
<Select label="Start Time" value={field.value} onChange={field.onChange}>
|
||||||
|
{CALENDAR_TIME_OPTIONS.map((time) => (
|
||||||
|
<MenuItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!allDay && (
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="End Date"
|
||||||
|
type="date"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
error={Boolean(errors.endDate)}
|
||||||
|
helperText={errors.endDate?.message}
|
||||||
|
{...register("endDate")}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="endTime"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>End Time</InputLabel>
|
||||||
|
<Select label="End Time" value={field.value} onChange={field.onChange}>
|
||||||
|
{CALENDAR_TIME_OPTIONS.map((time) => (
|
||||||
|
<MenuItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="allDay"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={field.value} onChange={field.onChange} />}
|
||||||
|
label="All day event"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate("/calendar")}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/(protected)/calendar/_layout.tsx
Normal file
13
src/app/(protected)/calendar/_layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Alert, Box } from "@mui/material";
|
||||||
|
|
||||||
|
export default function CalendarLayout() {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4">
|
||||||
|
<Alert severity="warning" className="mx-4 mt-4">
|
||||||
|
Calendário indisponível — a API de calendário ainda não está implementada no backend.
|
||||||
|
</Alert>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
1
src/app/(protected)/calendar/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/calendar-unavailable";
|
||||||
1
src/app/(protected)/calendar/index.tsx
Normal file
1
src/app/(protected)/calendar/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/calendar-unavailable";
|
||||||
1
src/app/(protected)/calendar/new.tsx
Normal file
1
src/app/(protected)/calendar/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/calendar-unavailable";
|
||||||
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { contactSchema, type ContactFormValues } from "@/domain/contacts/schemas/contact-schema";
|
||||||
|
import { useContactDetail } from "@/domain/contacts/use-cases/use-contact-detail";
|
||||||
|
import { useCreateContact } from "@/domain/contacts/use-cases/use-create-contact";
|
||||||
|
import { useUpdateContact } from "@/domain/contacts/use-cases/use-update-contact";
|
||||||
|
|
||||||
|
const defaultValues: ContactFormValues = {
|
||||||
|
firstName: "",
|
||||||
|
middleName: "",
|
||||||
|
lastName: "",
|
||||||
|
title: "",
|
||||||
|
contactType: "",
|
||||||
|
gender: "",
|
||||||
|
dateOfBirth: "",
|
||||||
|
territory: "-1",
|
||||||
|
owner: "1",
|
||||||
|
privacySetting: "PublicReadWrite",
|
||||||
|
about: "",
|
||||||
|
tags: "",
|
||||||
|
street: "",
|
||||||
|
streetLineTwo: "",
|
||||||
|
city: "",
|
||||||
|
zip: "",
|
||||||
|
country: "US",
|
||||||
|
state: "",
|
||||||
|
county: "",
|
||||||
|
timeZone: "Eastern Standard Time",
|
||||||
|
smsAddress: "",
|
||||||
|
website: "",
|
||||||
|
facebookUrl: "",
|
||||||
|
linkedInUrl: "",
|
||||||
|
twitterUrl: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTACT_TYPES = ["Corporate", "Store Mgr", "Facilities", "Area Leader"];
|
||||||
|
const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"];
|
||||||
|
|
||||||
|
export default function ContactFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: contact, isLoading, error } = useContactDetail(id);
|
||||||
|
const createContact = useCreateContact();
|
||||||
|
const updateContact = useUpdateContact();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContactFormValues>({
|
||||||
|
resolver: zodResolver(contactSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contact && isEdit) {
|
||||||
|
reset({
|
||||||
|
firstName: contact.firstName,
|
||||||
|
middleName: contact.middleName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
title: contact.title,
|
||||||
|
contactType: contact.contactType,
|
||||||
|
gender: contact.gender,
|
||||||
|
dateOfBirth: contact.dateOfBirth,
|
||||||
|
territory: contact.territory || "-1",
|
||||||
|
owner: contact.owner || "1",
|
||||||
|
privacySetting: contact.privacySetting,
|
||||||
|
about: contact.about,
|
||||||
|
tags: contact.tags,
|
||||||
|
street: contact.street,
|
||||||
|
streetLineTwo: contact.streetLineTwo,
|
||||||
|
city: contact.city,
|
||||||
|
zip: contact.zip,
|
||||||
|
country: contact.country,
|
||||||
|
state: contact.state,
|
||||||
|
county: contact.county,
|
||||||
|
timeZone: contact.timeZone,
|
||||||
|
smsAddress: contact.smsAddress,
|
||||||
|
website: contact.website,
|
||||||
|
facebookUrl: contact.facebookUrl,
|
||||||
|
linkedInUrl: contact.linkedInUrl,
|
||||||
|
twitterUrl: contact.twitterUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [contact, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createContact.isPending || updateContact.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: ContactFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateContact.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createContact.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load contact"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
General Details
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.firstName)}
|
||||||
|
helperText={errors.firstName?.message}
|
||||||
|
{...register("firstName")}
|
||||||
|
/>
|
||||||
|
<TextField label="Middle Name" fullWidth {...register("middleName")} />
|
||||||
|
<TextField
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.lastName)}
|
||||||
|
helperText={errors.lastName?.message}
|
||||||
|
{...register("lastName")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField label="Title" fullWidth {...register("title")} />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Contact Type</InputLabel>
|
||||||
|
<Select label="Contact Type" defaultValue="" {...register("contactType")}>
|
||||||
|
<MenuItem value="">Select type</MenuItem>
|
||||||
|
{CONTACT_TYPES.map((type) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Territory</InputLabel>
|
||||||
|
<Select label="Territory" required defaultValue="-1" {...register("territory")}>
|
||||||
|
<MenuItem value="">Select territory</MenuItem>
|
||||||
|
<MenuItem value="-1">No Territory</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Owner</InputLabel>
|
||||||
|
<Select label="Owner" required defaultValue="1" {...register("owner")}>
|
||||||
|
<MenuItem value="">Select owner</MenuItem>
|
||||||
|
<MenuItem value="1">Employee 1</MenuItem>
|
||||||
|
<MenuItem value="2">Employee 2</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Privacy</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Privacy"
|
||||||
|
defaultValue="PublicReadWrite"
|
||||||
|
{...register("privacySetting")}
|
||||||
|
>
|
||||||
|
<MenuItem value="PublicReadWrite">Public read/write</MenuItem>
|
||||||
|
<MenuItem value="PublicReadOnly">Public read only</MenuItem>
|
||||||
|
<MenuItem value="Private">Private</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField label="About" fullWidth {...register("about")} />
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Address
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField label="Address 1" fullWidth {...register("street")} />
|
||||||
|
<TextField label="Address 2" fullWidth {...register("streetLineTwo")} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField label="City" fullWidth {...register("city")} />
|
||||||
|
<TextField label="Zip" fullWidth {...register("zip")} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>State</InputLabel>
|
||||||
|
<Select label="State" defaultValue="" {...register("state")}>
|
||||||
|
<MenuItem value="">Select state</MenuItem>
|
||||||
|
{US_STATES.map((st) => (
|
||||||
|
<MenuItem key={st} value={st}>
|
||||||
|
{st}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Country" fullWidth {...register("country")} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Additional
|
||||||
|
</Typography>
|
||||||
|
<TextField label="Website" fullWidth placeholder="http://" {...register("website")} />
|
||||||
|
<TextField label="SMS Address" fullWidth {...register("smsAddress")} />
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate("/contacts")}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/contact-form";
|
||||||
200
src/app/(protected)/contacts/index.tsx
Normal file
200
src/app/(protected)/contacts/index.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { ContactListItem } from "@/domain/contacts/types/contact";
|
||||||
|
import { useContactsList } from "@/domain/contacts/use-cases/use-contacts-list";
|
||||||
|
import { useDeleteContact } from "@/domain/contacts/use-cases/use-delete-contact";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function ContactsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ContactListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteContact = useDeleteContact();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useContactsList(listParams);
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Contacts
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search contacts..."
|
||||||
|
className="min-w-sidebar"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/contacts/new")}>
|
||||||
|
+ New Contact
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load contacts"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Account</TableCell>
|
||||||
|
<TableCell className="font-semibold">Title</TableCell>
|
||||||
|
<TableCell className="font-semibold">Email</TableCell>
|
||||||
|
<TableCell className="font-semibold">Phone</TableCell>
|
||||||
|
<TableCell className="font-semibold">Owner</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No contacts found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/contacts/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.account || "—"}</TableCell>
|
||||||
|
<TableCell>{row.title || "—"}</TableCell>
|
||||||
|
<TableCell>{row.email || "—"}</TableCell>
|
||||||
|
<TableCell>{row.phoneNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.owner || "—"}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton size="small" onClick={() => navigate(`/contacts/edit/${row.id}`)}>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Contact</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"?
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteContact.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={deleteContact.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteContact.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/contacts/new.tsx
Normal file
1
src/app/(protected)/contacts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/contact-form";
|
||||||
221
src/app/(protected)/dashboard.tsx
Normal file
221
src/app/(protected)/dashboard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||||
|
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
|
||||||
|
|
||||||
|
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
|
||||||
|
|
||||||
|
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
value?: string | number;
|
||||||
|
icon: ReactNode;
|
||||||
|
color: "success" | "primary" | "info" | "warning";
|
||||||
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
|
||||||
|
const colorMap = {
|
||||||
|
success: "success.main",
|
||||||
|
primary: "primary.main",
|
||||||
|
info: "info.main",
|
||||||
|
warning: "warning.main",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
|
||||||
|
sx={{
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftStyle: "solid",
|
||||||
|
borderLeftColor: colorMap[color],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="flex w-full gap-4 p-4">
|
||||||
|
<Box
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
|
||||||
|
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="flex min-w-0 flex-col justify-center gap-1">
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{value !== undefined && (
|
||||||
|
<Typography variant="h4" component="p" className="font-bold leading-tight">
|
||||||
|
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
|
||||||
|
|
||||||
|
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||||
|
<KpiCard
|
||||||
|
title="Create a Work Order"
|
||||||
|
description="Report a problem you need fixed"
|
||||||
|
icon={<AddIcon />}
|
||||||
|
color="success"
|
||||||
|
onClick={() => navigate("/workorders/new")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Open Work Orders"
|
||||||
|
value={formatStat(stats?.open)}
|
||||||
|
icon={<FolderOpenOutlinedIcon />}
|
||||||
|
color="primary"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Not Dispatched"
|
||||||
|
value={formatStat(stats?.notDispatched)}
|
||||||
|
icon={<ScheduleOutlinedIcon />}
|
||||||
|
color="info"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Completed Orders"
|
||||||
|
value={formatStat(stats?.completed)}
|
||||||
|
icon={<CheckCircleOutlineIcon />}
|
||||||
|
color="warning"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="overflow-hidden">
|
||||||
|
<Box className="border-b border-border px-4 py-3">
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Priority Analysis
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Age of open Work Orders by Priority
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Priority</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Total
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
0-14 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
15-29 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
30-59 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
60+ Days
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{PRIORITY_LEVELS.map((priority) => (
|
||||||
|
<TableRow key={priority}>
|
||||||
|
<TableCell>{priority}</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="overflow-hidden">
|
||||||
|
<Box className="border-b border-border px-4 py-3">
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Invoice Analysis
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Count
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Amount
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{INVOICE_STATUSES.map((status) => (
|
||||||
|
<TableRow key={status}>
|
||||||
|
<TableCell>{status}</TableCell>
|
||||||
|
<TableCell align="right">0</TableCell>
|
||||||
|
<TableCell align="right">$0.00</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
361
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
resolveJobTitleId,
|
||||||
|
resolveJobTitleLabel,
|
||||||
|
} from "@/domain/employees/lib/resolve-job-title-id";
|
||||||
|
import {
|
||||||
|
employeeDefaultValues,
|
||||||
|
employeeSchema,
|
||||||
|
type EmployeeFormValues,
|
||||||
|
} from "@/domain/employees/schemas/employee-schema";
|
||||||
|
import { useCreateEmployee } from "@/domain/employees/use-cases/use-create-employee";
|
||||||
|
import { useEmployeeDetail } from "@/domain/employees/use-cases/use-employee-detail";
|
||||||
|
import { useUpdateEmployee } from "@/domain/employees/use-cases/use-update-employee";
|
||||||
|
import { JobTitleSelect } from "@/app/(protected)/employees/_components/job-title-select";
|
||||||
|
|
||||||
|
const DEPARTMENTS = ["Administration", "Sales", "Service", "Field Service"] as const;
|
||||||
|
const TIME_ZONES = [
|
||||||
|
"Eastern Standard Time",
|
||||||
|
"Central Standard Time",
|
||||||
|
"Mountain Standard Time",
|
||||||
|
"Pacific Standard Time",
|
||||||
|
] as const;
|
||||||
|
const ROLES = [
|
||||||
|
"Admin",
|
||||||
|
"Administrator User",
|
||||||
|
"Dispatcher",
|
||||||
|
"Editor User",
|
||||||
|
"Employee",
|
||||||
|
"Read Only User",
|
||||||
|
"Technician",
|
||||||
|
] as const;
|
||||||
|
const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"] as const;
|
||||||
|
|
||||||
|
function SelectField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
control,
|
||||||
|
options,
|
||||||
|
required,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
}: {
|
||||||
|
name: keyof EmployeeFormValues;
|
||||||
|
label: string;
|
||||||
|
control: ReturnType<typeof useForm<EmployeeFormValues>>["control"];
|
||||||
|
options: readonly string[];
|
||||||
|
required?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth required={required} error={error}>
|
||||||
|
<InputLabel id={`${name}-label`}>{label}</InputLabel>
|
||||||
|
<Select {...field} labelId={`${name}-label`} label={label} value={field.value ?? ""}>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Select...</em>
|
||||||
|
</MenuItem>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{helperText && (
|
||||||
|
<Typography variant="caption" color="error" className="mt-1">
|
||||||
|
{helperText}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmployeeFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: employee, isLoading, error } = useEmployeeDetail(id);
|
||||||
|
const createEmployee = useCreateEmployee();
|
||||||
|
const updateEmployee = useUpdateEmployee();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<EmployeeFormValues>({
|
||||||
|
resolver: zodResolver(employeeSchema),
|
||||||
|
defaultValues: employeeDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (employee && isEdit) {
|
||||||
|
const jobTitleId = resolveJobTitleId(employee.jobTitleId, employee.jobTitle);
|
||||||
|
const jobTitle = resolveJobTitleLabel(jobTitleId, employee.jobTitle);
|
||||||
|
|
||||||
|
reset({
|
||||||
|
firstName: employee.firstName,
|
||||||
|
middleName: employee.middleName,
|
||||||
|
lastName: employee.lastName,
|
||||||
|
email: employee.email,
|
||||||
|
phone: employee.phone,
|
||||||
|
jobTitle,
|
||||||
|
jobTitleId,
|
||||||
|
role: employee.role,
|
||||||
|
status: employee.status,
|
||||||
|
hireDate: employee.hireDate,
|
||||||
|
dateOfBirth: employee.dateOfBirth,
|
||||||
|
address: employee.address,
|
||||||
|
department: employee.department,
|
||||||
|
timeZone: employee.timeZone,
|
||||||
|
gender: employee.gender,
|
||||||
|
employeeNumber: employee.employeeNumber,
|
||||||
|
regionName: employee.regionName,
|
||||||
|
userName: employee.userName,
|
||||||
|
confirmUserName: employee.confirmUserName,
|
||||||
|
street: employee.street,
|
||||||
|
streetLineTwo: employee.streetLineTwo,
|
||||||
|
city: employee.city,
|
||||||
|
zip: employee.zip,
|
||||||
|
country: employee.country,
|
||||||
|
state: employee.state,
|
||||||
|
county: employee.county,
|
||||||
|
smsAddress: employee.smsAddress,
|
||||||
|
website: employee.website,
|
||||||
|
facebookUrl: employee.facebookUrl,
|
||||||
|
linkedInUrl: employee.linkedInUrl,
|
||||||
|
twitterUrl: employee.twitterUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [employee, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createEmployee.isPending || updateEmployee.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: EmployeeFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateEmployee.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createEmployee.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-5xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{isEdit ? "Edit an Employee" : "Create an Employee"}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="text" onClick={() => navigate("/employees")}>
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load employee"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
General Details
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("firstName")}
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.firstName)}
|
||||||
|
helperText={errors.firstName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField {...register("middleName")} label="Middle Name" fullWidth />
|
||||||
|
<TextField
|
||||||
|
{...register("lastName")}
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.lastName)}
|
||||||
|
helperText={errors.lastName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<SelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
control={control}
|
||||||
|
options={DEPARTMENTS}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
name="timeZone"
|
||||||
|
label="Time Zone"
|
||||||
|
control={control}
|
||||||
|
options={TIME_ZONES}
|
||||||
|
required
|
||||||
|
error={Boolean(errors.timeZone)}
|
||||||
|
helperText={errors.timeZone?.message}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
name="gender"
|
||||||
|
label="Gender"
|
||||||
|
control={control}
|
||||||
|
options={["Male", "Female", "Not specified", "Other"]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("dateOfBirth")}
|
||||||
|
label="Date of Birth"
|
||||||
|
type="date"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<JobTitleSelect control={control} setValue={setValue} />
|
||||||
|
<TextField {...register("employeeNumber")} label="Employee #" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("hireDate")}
|
||||||
|
label="Hire Date"
|
||||||
|
type="date"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
name="regionName"
|
||||||
|
label="Region Name"
|
||||||
|
control={control}
|
||||||
|
options={["Region 1", "Region 2"]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Login & Role
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{isEdit
|
||||||
|
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in."
|
||||||
|
: "A confirmation email will be sent to activate the Employee's account."}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<SelectField
|
||||||
|
name="role"
|
||||||
|
label="Role"
|
||||||
|
control={control}
|
||||||
|
options={ROLES}
|
||||||
|
required
|
||||||
|
error={Boolean(errors.role)}
|
||||||
|
helperText={errors.role?.message}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("userName")}
|
||||||
|
label="User Name"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.userName)}
|
||||||
|
helperText={errors.userName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("confirmUserName")}
|
||||||
|
label="Confirm User Name"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.confirmUserName)}
|
||||||
|
helperText={errors.confirmUserName?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Address
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("street")} label="Address 1" fullWidth />
|
||||||
|
<TextField {...register("streetLineTwo")} label="Address 2" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("city")} label="City" fullWidth />
|
||||||
|
<TextField {...register("zip")} label="Zip/Postal Code" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<SelectField name="country" label="Country" control={control} options={["US"]} />
|
||||||
|
<SelectField
|
||||||
|
name="state"
|
||||||
|
label="State / Province"
|
||||||
|
control={control}
|
||||||
|
options={US_STATES}
|
||||||
|
/>
|
||||||
|
<TextField {...register("county")} label="County" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Additional Details
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("smsAddress")} label="SMS Address" type="email" fullWidth />
|
||||||
|
<TextField {...register("website")} label="Website" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField {...register("facebookUrl")} label="Facebook" fullWidth />
|
||||||
|
<TextField {...register("linkedInUrl")} label="LinkedIn" fullWidth />
|
||||||
|
<TextField {...register("twitterUrl")} label="Twitter" fullWidth />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/employees")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Controller, type Control, type UseFormSetValue } from "react-hook-form";
|
||||||
|
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
|
||||||
|
import { JOB_TITLE_OPTIONS } from "@/domain/employees/constants/job-title-options";
|
||||||
|
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||||
|
|
||||||
|
interface JobTitleSelectProps {
|
||||||
|
control: Control<EmployeeFormValues>;
|
||||||
|
setValue: UseFormSetValue<EmployeeFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobTitleSelect({ control, setValue }: JobTitleSelectProps) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="jobTitleId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="jobTitleId-label">Job Title</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="jobTitleId-label"
|
||||||
|
label="Job Title"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
const nextId = value === "" ? null : Number(value);
|
||||||
|
field.onChange(nextId);
|
||||||
|
const label = JOB_TITLE_OPTIONS.find((option) => option.id === nextId)?.label ?? "";
|
||||||
|
setValue("jobTitle", label, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Select...</em>
|
||||||
|
</MenuItem>
|
||||||
|
{JOB_TITLE_OPTIONS.map((option) => (
|
||||||
|
<MenuItem key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/employee-form";
|
||||||
232
src/app/(protected)/employees/index.tsx
Normal file
232
src/app/(protected)/employees/index.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { EmployeeListItem } from "@/domain/employees/types/employee";
|
||||||
|
import { useDeleteEmployee } from "@/domain/employees/use-cases/use-delete-employee";
|
||||||
|
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, "default" | "success" | "warning" | "error"> = {
|
||||||
|
Active: "success",
|
||||||
|
Inactive: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EmployeesListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<EmployeeListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteEmployee = useDeleteEmployee();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useEmployeesList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmployee.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Employees
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search employees..."
|
||||||
|
className="min-w-sidebar"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/employees/new")}>
|
||||||
|
+ New Employee
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load employees"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Job Title</TableCell>
|
||||||
|
<TableCell className="font-semibold">Email</TableCell>
|
||||||
|
<TableCell className="font-semibold">Phone</TableCell>
|
||||||
|
<TableCell className="font-semibold">Department</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No employees found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/employees/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.jobTitle || "—"}</TableCell>
|
||||||
|
<TableCell>{row.email || "—"}</TableCell>
|
||||||
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
|
<TableCell>{row.department || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={row.status || "—"}
|
||||||
|
color={STATUS_COLORS[row.status] ?? "default"}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit employee"
|
||||||
|
onClick={() => navigate(`/employees/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete employee"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Employee</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteEmployee.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteEmployee.isPending}
|
||||||
|
>
|
||||||
|
{deleteEmployee.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/employees/new.tsx
Normal file
1
src/app/(protected)/employees/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/employee-form";
|
||||||
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal file
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||||
|
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
|
||||||
|
import {
|
||||||
|
followUpSchema,
|
||||||
|
type FollowUpFormValues,
|
||||||
|
} from "@/domain/follow-ups/schemas/follow-up-schema";
|
||||||
|
import { useCreateFollowUp } from "@/domain/follow-ups/use-cases/use-create-follow-up";
|
||||||
|
import { useFollowUpDetail } from "@/domain/follow-ups/use-cases/use-follow-up-detail";
|
||||||
|
import { useUpdateFollowUp } from "@/domain/follow-ups/use-cases/use-update-follow-up";
|
||||||
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
|
|
||||||
|
const defaultValues: FollowUpFormValues = {
|
||||||
|
reason: "",
|
||||||
|
scheduleDate: "",
|
||||||
|
scheduleStartTime: "09:00",
|
||||||
|
employeeId: null,
|
||||||
|
accountId: null,
|
||||||
|
locationId: null,
|
||||||
|
workOrderId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FollowUpFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: followUp, isLoading, error } = useFollowUpDetail(id);
|
||||||
|
const { data: employeesData } = useEmployeesList({ page: 1, pageSize: 500 });
|
||||||
|
const { data: accountsData } = useAccountsList({ page: 1, pageSize: 500 });
|
||||||
|
const { data: locationsData } = useLocationsList({ page: 1, pageSize: 500 });
|
||||||
|
|
||||||
|
const createFollowUp = useCreateFollowUp();
|
||||||
|
const updateFollowUp = useUpdateFollowUp();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FollowUpFormValues>({
|
||||||
|
resolver: zodResolver(followUpSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (followUp && isEdit) {
|
||||||
|
reset({
|
||||||
|
reason: followUp.reason,
|
||||||
|
scheduleDate: followUp.scheduleDate,
|
||||||
|
scheduleStartTime: followUp.scheduleStartTime,
|
||||||
|
employeeId: followUp.employeeId,
|
||||||
|
accountId: followUp.accountId,
|
||||||
|
locationId: followUp.locationId,
|
||||||
|
workOrderId: followUp.workOrderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [followUp, isEdit, reset]);
|
||||||
|
|
||||||
|
const employees = employeesData?.items ?? [];
|
||||||
|
const accounts = accountsData?.items ?? [];
|
||||||
|
const locations = locationsData?.items ?? [];
|
||||||
|
const isSaving = createFollowUp.isPending || updateFollowUp.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: FollowUpFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateFollowUp.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createFollowUp.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load follow-up"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<TextField
|
||||||
|
label="Reason"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
error={Boolean(errors.reason)}
|
||||||
|
helperText={errors.reason?.message}
|
||||||
|
{...register("reason")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Schedule Date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.scheduleDate)}
|
||||||
|
helperText={errors.scheduleDate?.message}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
{...register("scheduleDate")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Start Time"
|
||||||
|
type="time"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.scheduleStartTime)}
|
||||||
|
helperText={errors.scheduleStartTime?.message}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
{...register("scheduleStartTime")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="employeeId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Assigned Employee</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Assigned Employee"
|
||||||
|
value={field.value != null ? String(field.value) : ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">None</MenuItem>
|
||||||
|
{employees.map((emp) => (
|
||||||
|
<MenuItem key={String(emp.id)} value={String(emp.id)}>
|
||||||
|
{emp.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Account</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Account"
|
||||||
|
value={field.value != null ? String(field.value) : ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">None</MenuItem>
|
||||||
|
{accounts.map((acc) => (
|
||||||
|
<MenuItem key={String(acc.id)} value={String(acc.id)}>
|
||||||
|
{acc.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="locationId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Location</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
value={field.value != null ? String(field.value) : ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">None</MenuItem>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<MenuItem key={String(loc.id)} value={String(loc.id)}>
|
||||||
|
{loc.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} />
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate("/followups")}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/follow-up-form";
|
||||||
234
src/app/(protected)/followups/index.tsx
Normal file
234
src/app/(protected)/followups/index.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
|
||||||
|
import { useDeleteFollowUp } from "@/domain/follow-ups/use-cases/use-delete-follow-up";
|
||||||
|
import { useFollowUpsList } from "@/domain/follow-ups/use-cases/use-follow-ups-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["Open", "Completed", "Cancelled", ""];
|
||||||
|
|
||||||
|
export default function FollowUpsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [status, setStatus] = useState("Open");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<FollowUpListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteFollowUp = useDeleteFollowUp();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({ search: debouncedSearch, page: page + 1, pageSize, status }),
|
||||||
|
[debouncedSearch, page, pageSize, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useFollowUpsList(listParams);
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Follow-ups
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search follow-ups..."
|
||||||
|
className="min-w-sidebar"
|
||||||
|
/>
|
||||||
|
<FormControl size="small" className="min-w-[140px]">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s || "all"} value={s}>
|
||||||
|
{s || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/followups/new")}>
|
||||||
|
+ New Follow-up
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Reason</TableCell>
|
||||||
|
<TableCell className="font-semibold">Schedule</TableCell>
|
||||||
|
<TableCell className="font-semibold">Assigned To</TableCell>
|
||||||
|
<TableCell className="font-semibold">Account</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
<TableCell className="font-semibold">WO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No follow-ups found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.reason || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{toDateInputValue(row.scheduleDate) || "—"}
|
||||||
|
{row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.assignedTo || "—"}</TableCell>
|
||||||
|
<TableCell>{row.accountName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.locationName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.internalWONumber || row.workOrderNumber || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || "—"} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Follow-up</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>Are you sure you want to delete this follow-up?</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteFollowUp.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={deleteFollowUp.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteFollowUp.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/followups/new.tsx
Normal file
1
src/app/(protected)/followups/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/follow-up-form";
|
||||||
221
src/app/(protected)/index.tsx
Normal file
221
src/app/(protected)/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||||
|
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
|
||||||
|
|
||||||
|
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
|
||||||
|
|
||||||
|
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
value?: string | number;
|
||||||
|
icon: ReactNode;
|
||||||
|
color: "success" | "primary" | "info" | "warning";
|
||||||
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
|
||||||
|
const colorMap = {
|
||||||
|
success: "success.main",
|
||||||
|
primary: "primary.main",
|
||||||
|
info: "info.main",
|
||||||
|
warning: "warning.main",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
|
||||||
|
sx={{
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftStyle: "solid",
|
||||||
|
borderLeftColor: colorMap[color],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="flex w-full gap-4 p-4">
|
||||||
|
<Box
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
|
||||||
|
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="flex min-w-0 flex-col justify-center gap-1">
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{value !== undefined && (
|
||||||
|
<Typography variant="h4" component="p" className="font-bold leading-tight">
|
||||||
|
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
|
||||||
|
|
||||||
|
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||||
|
<KpiCard
|
||||||
|
title="Create a Work Order"
|
||||||
|
description="Report a problem you need fixed"
|
||||||
|
icon={<AddIcon />}
|
||||||
|
color="success"
|
||||||
|
onClick={() => navigate("/workorders/new")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Open Work Orders"
|
||||||
|
value={formatStat(stats?.open)}
|
||||||
|
icon={<FolderOpenOutlinedIcon />}
|
||||||
|
color="primary"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Not Dispatched"
|
||||||
|
value={formatStat(stats?.notDispatched)}
|
||||||
|
icon={<ScheduleOutlinedIcon />}
|
||||||
|
color="info"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Completed Orders"
|
||||||
|
value={formatStat(stats?.completed)}
|
||||||
|
icon={<CheckCircleOutlineIcon />}
|
||||||
|
color="warning"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => navigate("/workorders")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="overflow-hidden">
|
||||||
|
<Box className="border-b border-border px-4 py-3">
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Priority Analysis
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Age of open Work Orders by Priority
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Priority</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Total
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
0-14 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
15-29 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
30-59 Days
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
60+ Days
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{PRIORITY_LEVELS.map((priority) => (
|
||||||
|
<TableRow key={priority}>
|
||||||
|
<TableCell>{priority}</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
<TableCell align="right">—</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="overflow-hidden">
|
||||||
|
<Box className="border-b border-border px-4 py-3">
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Invoice Analysis
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Count
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Amount
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{INVOICE_STATUSES.map((status) => (
|
||||||
|
<TableRow key={status}>
|
||||||
|
<TableCell>{status}</TableCell>
|
||||||
|
<TableCell align="right">0</TableCell>
|
||||||
|
<TableCell align="right">$0.00</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/app/(protected)/locations/_components/location-form.tsx
Normal file
267
src/app/(protected)/locations/_components/location-form.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
|
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||||
|
import {
|
||||||
|
locationDefaultValues,
|
||||||
|
locationSchema,
|
||||||
|
type LocationFormValues,
|
||||||
|
} from "@/domain/locations/schemas/location-schema";
|
||||||
|
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||||
|
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function LocationFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const [accountSearch, setAccountSearch] = useState("");
|
||||||
|
const debouncedAccountSearch = useDebounce(accountSearch, 300);
|
||||||
|
|
||||||
|
const { data: location, isLoading, error } = useLocationDetail(id);
|
||||||
|
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
|
||||||
|
search: debouncedAccountSearch,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
const createLocation = useCreateLocation();
|
||||||
|
const updateLocation = useUpdateLocation();
|
||||||
|
|
||||||
|
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LocationFormValues>({
|
||||||
|
resolver: zodResolver(locationSchema),
|
||||||
|
defaultValues: locationDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location && isEdit) {
|
||||||
|
reset({
|
||||||
|
name: location.name ?? "",
|
||||||
|
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||||
|
address: location.address ?? "",
|
||||||
|
city: location.city ?? "",
|
||||||
|
state: (location.state as LocationFormValues["state"]) || "IL",
|
||||||
|
zipCode: location.zipCode ?? "",
|
||||||
|
phone: location.phone ?? "",
|
||||||
|
contact: location.contact ?? "",
|
||||||
|
contactEmail: location.contactEmail ?? "",
|
||||||
|
status: (location.status as LocationFormValues["status"]) || "Active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createLocation.isPending || updateLocation.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: LocationFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateLocation.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createLocation.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{isEdit ? "Edit Location" : "Create a Location"}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="text" onClick={() => navigate("/locations")}>
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load location"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Location Information
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("name")}
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={accountOptions}
|
||||||
|
loading={isLoadingAccounts}
|
||||||
|
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
String(option.id) === String(value.id)
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
accountOptions.find((account) => String(account.id) === field.value) ?? null
|
||||||
|
}
|
||||||
|
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
setAccountSearch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
{...register("address")}
|
||||||
|
label="Address"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.address)}
|
||||||
|
helperText={errors.address?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("city")}
|
||||||
|
label="City"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.city)}
|
||||||
|
helperText={errors.city?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="state"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth required error={Boolean(errors.state)}>
|
||||||
|
<InputLabel id="state-label">State</InputLabel>
|
||||||
|
<Select {...field} labelId="state-label" label="State" value={field.value}>
|
||||||
|
{LOCATION_STATE_OPTIONS.map((state) => (
|
||||||
|
<MenuItem key={state} value={state}>
|
||||||
|
{state}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("zipCode")}
|
||||||
|
label="Zip Code"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.zipCode)}
|
||||||
|
helperText={errors.zipCode?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Contact Information
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("phone")}
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.phone)}
|
||||||
|
helperText={errors.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("contact")}
|
||||||
|
label="Contact Person"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.contact)}
|
||||||
|
helperText={errors.contact?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("contactEmail")}
|
||||||
|
label="Contact Email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.contactEmail)}
|
||||||
|
helperText={errors.contactEmail?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth required error={Boolean(errors.status)}>
|
||||||
|
<InputLabel id="status-label">Status</InputLabel>
|
||||||
|
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
||||||
|
{LOCATION_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/location-form";
|
||||||
221
src/app/(protected)/locations/index.tsx
Normal file
221
src/app/(protected)/locations/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||||
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function LocationsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteLocation = useDeleteLocation();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLocation.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Locations
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search locations..."
|
||||||
|
className="min-w-[220px]"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/locations/new")}>
|
||||||
|
+ New Location
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Location Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Address</TableCell>
|
||||||
|
<TableCell className="font-semibold">City</TableCell>
|
||||||
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
|
<TableCell className="font-semibold">Phone</TableCell>
|
||||||
|
<TableCell className="font-semibold">Contact</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No locations found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.address || "—"}</TableCell>
|
||||||
|
<TableCell>{row.city || "—"}</TableCell>
|
||||||
|
<TableCell>{row.state || "—"}</TableCell>
|
||||||
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
|
<TableCell>{row.contact || "—"}</TableCell>
|
||||||
|
<TableCell>{row.status || "—"}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit location"
|
||||||
|
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete location"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Location</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deleteLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteLocation.isPending}
|
||||||
|
>
|
||||||
|
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/locations/new.tsx
Normal file
1
src/app/(protected)/locations/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/location-form";
|
||||||
210
src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
Normal file
210
src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
pmScheduleDefaultValues,
|
||||||
|
pmScheduleSchema,
|
||||||
|
type PmScheduleFormValues,
|
||||||
|
} from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
||||||
|
import { usePmScheduleDetail } from "@/domain/pm-schedules/use-cases/use-pm-schedule-detail";
|
||||||
|
import { usePmScheduleFormOptions } from "@/domain/pm-schedules/use-cases/use-pm-schedule-form-options";
|
||||||
|
import { useSavePmSchedule } from "@/domain/pm-schedules/use-cases/use-save-pm-schedule";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
export default function PmScheduleFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: schedule, isLoading, error } = usePmScheduleDetail(id);
|
||||||
|
const {
|
||||||
|
assets,
|
||||||
|
locations,
|
||||||
|
isLoading: optionsLoading,
|
||||||
|
error: optionsError,
|
||||||
|
} = usePmScheduleFormOptions();
|
||||||
|
const savePmSchedule = useSavePmSchedule(id);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<PmScheduleFormValues>({
|
||||||
|
resolver: zodResolver(pmScheduleSchema),
|
||||||
|
defaultValues: pmScheduleDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (schedule && isEdit) {
|
||||||
|
reset({
|
||||||
|
name: schedule.name,
|
||||||
|
frequency: schedule.frequency as PmScheduleFormValues["frequency"],
|
||||||
|
startDate: toDateInputValue(schedule.startDate),
|
||||||
|
isActive: schedule.isActive,
|
||||||
|
assetId: schedule.assetId,
|
||||||
|
locationId: schedule.locationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [schedule, isEdit, reset]);
|
||||||
|
|
||||||
|
if ((isEdit && isLoading) || optionsLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
||||||
|
|
||||||
|
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
|
||||||
|
{optionsError && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit((values) => savePmSchedule.mutate(values))}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="frequency"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Frequency</InputLabel>
|
||||||
|
<Select label="Frequency" value={field.value} onChange={field.onChange}>
|
||||||
|
{["Daily", "Weekly", "Monthly", "Quarterly", "Semi-Annual", "Annual"].map(
|
||||||
|
(value) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Start Date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
error={Boolean(errors.startDate)}
|
||||||
|
helperText={errors.startDate?.message}
|
||||||
|
{...register("startDate")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="assetId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Asset</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Asset"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
field.onChange(value === "" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>None</em>
|
||||||
|
</MenuItem>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<MenuItem key={String(asset.id)} value={asset.id}>
|
||||||
|
{asset.name || asset.assetCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="locationId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Location</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
field.onChange(value === "" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>None</em>
|
||||||
|
</MenuItem>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<MenuItem key={String(location.id)} value={location.id}>
|
||||||
|
{location.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isActive"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={savePmSchedule.isPending}>
|
||||||
|
{savePmSchedule.isPending ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function PmScheduleEditUnavailablePanel() {
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-3xl">
|
||||||
|
<Alert severity="warning" className="mb-4">
|
||||||
|
PM Schedule editing is unavailable — the production API does not expose{" "}
|
||||||
|
<code className="text-sm">PMSchedule/GetById</code>.
|
||||||
|
</Alert>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
List and create remain available via <code className="text-sm">GetList</code> and{" "}
|
||||||
|
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
|
||||||
|
GetById.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PmScheduleEditUnavailablePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="p-4">
|
||||||
|
<PmScheduleEditUnavailablePanel />
|
||||||
|
<Box className="mt-4 flex justify-end">
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
|
||||||
|
Back to PM Schedules
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(protected)/pmschedules/edit/[id].tsx
Normal file
5
src/app/(protected)/pmschedules/edit/[id].tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
|
||||||
|
import PmScheduleFormPage from "../_components/pm-schedule-form";
|
||||||
|
import PmScheduleEditUnavailablePage from "../_components/pm-schedule-unavailable";
|
||||||
|
|
||||||
|
export default pmScheduleCapabilities.canEdit ? PmScheduleFormPage : PmScheduleEditUnavailablePage;
|
||||||
246
src/app/(protected)/pmschedules/index.tsx
Normal file
246
src/app/(protected)/pmschedules/index.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
|
||||||
|
import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule";
|
||||||
|
import { useDeletePmSchedule } from "@/domain/pm-schedules/use-cases/use-delete-pm-schedule";
|
||||||
|
import { usePmSchedulesList } from "@/domain/pm-schedules/use-cases/use-pm-schedules-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function PmSchedulesListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<PmScheduleListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deletePmSchedule = useDeletePmSchedule();
|
||||||
|
|
||||||
|
const showActionsColumn = pmScheduleCapabilities.canEdit || pmScheduleCapabilities.canDelete;
|
||||||
|
const columnCount = showActionsColumn ? 7 : 6;
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = usePmSchedulesList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget || !pmScheduleCapabilities.canDelete) return;
|
||||||
|
deletePmSchedule.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
PM Schedules
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search PM schedules..."
|
||||||
|
className="min-w-[220px]"
|
||||||
|
/>
|
||||||
|
{pmScheduleCapabilities.canCreate && (
|
||||||
|
<Button variant="contained" onClick={() => navigate("/pmschedules/new")}>
|
||||||
|
+ New PM Schedule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load PM schedules"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Frequency</TableCell>
|
||||||
|
<TableCell className="font-semibold">Start Date</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell className="font-semibold">Asset</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
{showActionsColumn && (
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No PM schedules found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
{pmScheduleCapabilities.canEdit ? (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" className="font-semibold">
|
||||||
|
{row.name || "—"}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.frequency || "—"}</TableCell>
|
||||||
|
<TableCell>{row.startDate || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
|
size="small"
|
||||||
|
color={row.isActive ? "success" : "default"}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.asset || "—"}</TableCell>
|
||||||
|
<TableCell>{row.location || "—"}</TableCell>
|
||||||
|
{showActionsColumn && (
|
||||||
|
<TableCell align="right">
|
||||||
|
{pmScheduleCapabilities.canEdit && (
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit PM schedule"
|
||||||
|
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{pmScheduleCapabilities.canDelete && (
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete PM schedule"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
setPageSize(Number(event.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pmScheduleCapabilities.canDelete && (
|
||||||
|
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete PM Schedule</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot
|
||||||
|
be undone.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deletePmSchedule.isPending}
|
||||||
|
>
|
||||||
|
{deletePmSchedule.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/pmschedules/new.tsx
Normal file
1
src/app/(protected)/pmschedules/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/pm-schedule-form";
|
||||||
297
src/app/(protected)/settings/dropdowns.tsx
Normal file
297
src/app/(protected)/settings/dropdowns.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { SettingsNav } from "@/components/common/settings-nav";
|
||||||
|
import {
|
||||||
|
DROPDOWN_OPTION_CATEGORIES,
|
||||||
|
type DropdownOption,
|
||||||
|
type DropdownOptionCategory,
|
||||||
|
} from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||||
|
import { useCreateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-create-dropdown-option";
|
||||||
|
import { useDeleteDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import { useUpdateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-update-dropdown-option";
|
||||||
|
|
||||||
|
export default function DropdownOptionsPage() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState<DropdownOptionCategory>("Trade");
|
||||||
|
const [newValue, setNewValue] = useState("");
|
||||||
|
const [newParent, setNewParent] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
const categoryConfig = useMemo(
|
||||||
|
() => DROPDOWN_OPTION_CATEGORIES.find((category) => category.key === activeCategory),
|
||||||
|
[activeCategory],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentCategory =
|
||||||
|
categoryConfig && "parent" in categoryConfig ? categoryConfig.parent : undefined;
|
||||||
|
|
||||||
|
const { data: options = [], isLoading, error } = useDropdownOptionsByCategory(activeCategory);
|
||||||
|
const { data: parentOptions = [] } = useDropdownOptionsByCategory("Trade", {
|
||||||
|
enabled: Boolean(parentCategory),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useCreateDropdownOption();
|
||||||
|
const updateMutation = useUpdateDropdownOption();
|
||||||
|
const deleteMutation = useDeleteDropdownOption();
|
||||||
|
|
||||||
|
const resetFormState = () => {
|
||||||
|
setNewValue("");
|
||||||
|
setNewParent("");
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = (category: DropdownOptionCategory) => {
|
||||||
|
if (category !== activeCategory) {
|
||||||
|
resetFormState();
|
||||||
|
}
|
||||||
|
setActiveCategory(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const value = newValue.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
category: activeCategory,
|
||||||
|
value,
|
||||||
|
parentValue: parentCategory ? newParent || null : null,
|
||||||
|
sortOrder: options.length,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewValue("");
|
||||||
|
setNewParent("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = (item: DropdownOption) => {
|
||||||
|
const value = editValue.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
category: item.category,
|
||||||
|
value,
|
||||||
|
parentValue: item.parentValue,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
isActive: item.isActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingId(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string | number) => {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlaceholder = `Add new ${categoryConfig?.label.slice(0, -1) ?? "option"}...`;
|
||||||
|
const isMutating =
|
||||||
|
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<SettingsNav />
|
||||||
|
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Dropdown Options
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Manage configurable dropdown values for work orders
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
|
||||||
|
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
|
||||||
|
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
|
||||||
|
<Button
|
||||||
|
key={category.key}
|
||||||
|
onClick={() => handleCategoryChange(category.key)}
|
||||||
|
className={`justify-start rounded-none border-b border-border px-4 py-3 text-left normal-case ${
|
||||||
|
activeCategory === category.key
|
||||||
|
? "border-r-2 border-r-primary bg-background font-semibold text-primary"
|
||||||
|
: "font-medium text-muted-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box className="flex flex-1 flex-col gap-4 p-4">
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
|
||||||
|
{parentCategory && (
|
||||||
|
<FormControl size="small" className="min-w-[160px]">
|
||||||
|
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="parent-trade-label"
|
||||||
|
label="Parent Trade"
|
||||||
|
value={newParent}
|
||||||
|
onChange={(event) => setNewParent(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Parent: All</MenuItem>
|
||||||
|
{parentOptions.map((option) => (
|
||||||
|
<MenuItem key={String(option.id)} value={option.value}>
|
||||||
|
{option.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={newValue}
|
||||||
|
onChange={(event) => setNewValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={addPlaceholder}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newValue.trim() || isMutating}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load options"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack alignItems="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Stack>
|
||||||
|
) : options.length === 0 ? (
|
||||||
|
<Typography align="center" color="text.secondary" className="py-10">
|
||||||
|
No options configured yet
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{options.map((item) => (
|
||||||
|
<Paper
|
||||||
|
key={String(item.id)}
|
||||||
|
variant="outlined"
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(event) => setEditValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
handleEditSave(item);
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
|
||||||
|
<Typography variant="body2" className="truncate">
|
||||||
|
{item.value}
|
||||||
|
</Typography>
|
||||||
|
{item.parentValue && (
|
||||||
|
<Chip size="small" label={item.parentValue} variant="outlined" />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} className="shrink-0">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
onClick={() => handleEditSave(item)}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditValue(item.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
src/app/(protected)/settings/task-templates.tsx
Normal file
274
src/app/(protected)/settings/task-templates.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { SettingsNav } from "@/components/common/settings-nav";
|
||||||
|
import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper";
|
||||||
|
import type {
|
||||||
|
TaskTemplate,
|
||||||
|
TaskTemplateFormValues,
|
||||||
|
} from "@/domain/settings/task-templates/types/task-template";
|
||||||
|
import {
|
||||||
|
useCreateTaskTemplate,
|
||||||
|
useDeleteTaskTemplate,
|
||||||
|
useUpdateTaskTemplate,
|
||||||
|
} from "@/domain/settings/task-templates/use-cases/use-task-template-mutations";
|
||||||
|
import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
|
||||||
|
|
||||||
|
const emptyForm: TaskTemplateFormValues = { name: "", description: "", items: [] };
|
||||||
|
|
||||||
|
export default function TaskTemplatesPage() {
|
||||||
|
const { data: templates = [], isLoading, error } = useTaskTemplates();
|
||||||
|
const createTemplate = useCreateTaskTemplate();
|
||||||
|
const updateTemplate = useUpdateTaskTemplate();
|
||||||
|
const deleteTemplate = useDeleteTaskTemplate();
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | number | "new" | null>(null);
|
||||||
|
const [form, setForm] = useState<TaskTemplateFormValues>(emptyForm);
|
||||||
|
const [newItemText, setNewItemText] = useState("");
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const isNew = selectedId === "new";
|
||||||
|
const isSaving = createTemplate.isPending || updateTemplate.isPending;
|
||||||
|
|
||||||
|
const handleSelect = (template: TaskTemplate) => {
|
||||||
|
setSelectedId(template.id);
|
||||||
|
setForm(mapTaskTemplateToFormValues(template));
|
||||||
|
setNewItemText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setSelectedId("new");
|
||||||
|
setForm(emptyForm);
|
||||||
|
setNewItemText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
if (!newItemText.trim()) return;
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, { text: newItemText.trim(), sortOrder: prev.items.length }],
|
||||||
|
}));
|
||||||
|
setNewItemText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveItem = (index: number, direction: -1 | 1) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const items = [...prev.items];
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= items.length) return prev;
|
||||||
|
[items[index], items[target]] = [items[target], items[index]];
|
||||||
|
return { ...prev, items: items.map((item, i) => ({ ...item, sortOrder: i })) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
if (isNew) {
|
||||||
|
createTemplate.mutate(form, {
|
||||||
|
onSuccess: (result) => setSelectedId(result.id),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedId && selectedId !== "new") {
|
||||||
|
updateTemplate.mutate({ id: selectedId, values: form });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!selectedId || selectedId === "new") return;
|
||||||
|
deleteTemplate.mutate(selectedId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="p-4">
|
||||||
|
<SettingsNav />
|
||||||
|
|
||||||
|
<Stack spacing={1} className="mb-4">
|
||||||
|
<Typography variant="h5">Task List Templates</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Create reusable checklists for work order dispatches
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load templates"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
|
||||||
|
<Paper variant="outlined" className="p-3">
|
||||||
|
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
|
||||||
|
+ New Template
|
||||||
|
</Button>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box className="flex justify-center p-4">
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={String(template.id)}
|
||||||
|
selected={selectedId === template.id}
|
||||||
|
onClick={() => handleSelect(template)}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={template.name}
|
||||||
|
secondary={`${template.items.length} items`}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
{!selectedId ? (
|
||||||
|
<Typography color="text.secondary" className="py-10 text-center">
|
||||||
|
Select a template or create a new one
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Template Name"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g., HVAC Inspection"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
|
Checklist Items ({form.items.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{form.items.map((item, index) => (
|
||||||
|
<Box
|
||||||
|
key={`${item.sortOrder}-${index}`}
|
||||||
|
className="flex items-center gap-2 rounded border border-border p-2"
|
||||||
|
>
|
||||||
|
<Typography variant="body2" className="w-6 text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="flex-1">
|
||||||
|
{item.text}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => handleMoveItem(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={index === form.items.length - 1}
|
||||||
|
onClick={() => handleMoveItem(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" color="error" onClick={() => handleRemoveItem(index)}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={newItemText}
|
||||||
|
onChange={(e) => setNewItemText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddItem();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add checklist item..."
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" onClick={handleAddItem} disabled={!newItemText.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
{!isNew && (
|
||||||
|
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !form.name.trim()}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : isNew ? "Create Template" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
|
||||||
|
<DialogTitle>Delete Template</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to delete "{form.name}"?
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
|
||||||
|
<Button color="error" variant="contained" onClick={handleDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/app/(protected)/uplifts/index.tsx
Normal file
312
src/app/(protected)/uplifts/index.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
|
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||||
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""];
|
||||||
|
|
||||||
|
function formatMoney(value: number | null | undefined): string {
|
||||||
|
return `$${Number(value ?? 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpliftQueuePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState("Pending");
|
||||||
|
const [tier, setTier] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize] = useState(25);
|
||||||
|
|
||||||
|
const [upliftAction, setUpliftAction] = useState<{
|
||||||
|
type: "approve" | "deny";
|
||||||
|
item: UpliftQueueItem;
|
||||||
|
} | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({ status, tier, page: page + 1, pageSize }),
|
||||||
|
[status, tier, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
|
||||||
|
const approveUplift = useApproveUplift();
|
||||||
|
const denyUplift = useDenyUplift();
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!upliftAction) return;
|
||||||
|
if (upliftAction.type === "approve") {
|
||||||
|
approveUplift.mutate(
|
||||||
|
{ id: upliftAction.item.id, note: note || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!note.trim()) return;
|
||||||
|
denyUplift.mutate(
|
||||||
|
{ id: upliftAction.item.id, note: note.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Uplift Queue
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<FormControl size="small" className="min-w-[140px]">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s || "all"} value={s}>
|
||||||
|
{s || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" className="min-w-[120px]">
|
||||||
|
<InputLabel>Tier</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Tier"
|
||||||
|
value={tier}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTier(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
<MenuItem value="1">Tier 1</MenuItem>
|
||||||
|
<MenuItem value="2">Tier 2</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Dispatch</TableCell>
|
||||||
|
<TableCell className="font-semibold">PO</TableCell>
|
||||||
|
<TableCell className="font-semibold">Vendor</TableCell>
|
||||||
|
<TableCell className="font-semibold">Current NTE</TableCell>
|
||||||
|
<TableCell className="font-semibold">Requested</TableCell>
|
||||||
|
<TableCell className="font-semibold">Delta</TableCell>
|
||||||
|
<TableCell className="font-semibold">Tier</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell className="font-semibold">Requested At</TableCell>
|
||||||
|
<TableCell className="font-semibold">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No uplift requests match these filters.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>{row.dispatchNumber}</TableCell>
|
||||||
|
<TableCell>{row.poNumber}</TableCell>
|
||||||
|
<TableCell>{row.vendorCompanyName}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.delta)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={`Tier ${row.requiredTier}`} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{row.workOrderId && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.status === "Pending" && (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
disabled={!row.canDecide}
|
||||||
|
onClick={() => {
|
||||||
|
setUpliftAction({ type: "approve", item: row });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={!row.canDecide}
|
||||||
|
onClick={() => {
|
||||||
|
setUpliftAction({ type: "deny", item: row });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
rowsPerPageOptions={[25]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||||
|
<DialogTitle>
|
||||||
|
{upliftAction?.type === "approve"
|
||||||
|
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
|
||||||
|
: "Deny Uplift"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{upliftAction?.type === "approve" && (
|
||||||
|
<Typography variant="body2" className="mt-1 mb-3">
|
||||||
|
{formatMoney(upliftAction.item.currentNTE)} →{" "}
|
||||||
|
{formatMoney(upliftAction.item.requestedNTE)} (+
|
||||||
|
{formatMoney(upliftAction.item.delta)})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
className="mt-2"
|
||||||
|
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color={upliftAction?.type === "approve" ? "success" : "error"}
|
||||||
|
disabled={
|
||||||
|
upliftAction?.type === "deny"
|
||||||
|
? !note.trim() || denyUplift.isPending
|
||||||
|
: approveUplift.isPending
|
||||||
|
}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
src/app/(protected)/vendor-pos/[id].tsx
Normal file
345
src/app/(protected)/vendor-pos/[id].tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||||
|
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
||||||
|
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
function formatMoney(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `$${Number(value).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{children == null || children === "" ? "—" : children}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorPoViewPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
||||||
|
|
||||||
|
const approveUplift = useApproveUplift(id);
|
||||||
|
const denyUplift = useDenyUplift(id);
|
||||||
|
|
||||||
|
const [upliftAction, setUpliftAction] = useState<{
|
||||||
|
type: "approve" | "deny";
|
||||||
|
uplift: UpliftRequest;
|
||||||
|
} | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const handleUpliftConfirm = () => {
|
||||||
|
if (!upliftAction) return;
|
||||||
|
if (upliftAction.type === "approve") {
|
||||||
|
approveUplift.mutate(
|
||||||
|
{ id: upliftAction.uplift.id, note: note || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!note.trim()) return;
|
||||||
|
denyUplift.mutate(
|
||||||
|
{ id: upliftAction.uplift.id, note: note.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setUpliftAction(null);
|
||||||
|
setNote("");
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !dispatch) {
|
||||||
|
return (
|
||||||
|
<Box className="p-4">
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Vendor PO not found"}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = dispatch.vendor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-5xl p-4">
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
|
||||||
|
<Button variant="text" onClick={() => navigate(-1)}>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={() => window.print()}>
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1} className="mb-4">
|
||||||
|
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Dispatch {dispatch.dispatchNumber || "—"}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" label={dispatch.status || "—"} />
|
||||||
|
{vendor?.companyName && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
· {vendor.companyName}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{dispatch.nteAmount != null && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
· NTE {formatMoney(dispatch.nteAmount)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Vendor
|
||||||
|
</Typography>
|
||||||
|
{vendor ? (
|
||||||
|
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
<InfoRow label="Company">{vendor.companyName}</InfoRow>
|
||||||
|
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
|
||||||
|
<InfoRow label="Email">{vendor.email}</InfoRow>
|
||||||
|
<InfoRow label="Phone">{vendor.phone}</InfoRow>
|
||||||
|
<InfoRow label="Address">
|
||||||
|
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">No vendor attached.</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
PO Details
|
||||||
|
</Typography>
|
||||||
|
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
|
||||||
|
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
|
||||||
|
<InfoRow label="Status">{dispatch.status}</InfoRow>
|
||||||
|
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
|
||||||
|
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
|
||||||
|
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
|
||||||
|
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
|
||||||
|
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
|
||||||
|
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||||
|
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||||
|
</Box>
|
||||||
|
{dispatch.description && (
|
||||||
|
<Box className="mt-3">
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Description
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||||
|
{dispatch.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{dispatch.primaryLocation && (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Site
|
||||||
|
</Typography>
|
||||||
|
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
|
||||||
|
<InfoRow label="Address">
|
||||||
|
{[
|
||||||
|
dispatch.primaryLocation.address,
|
||||||
|
dispatch.primaryLocation.city,
|
||||||
|
dispatch.primaryLocation.state,
|
||||||
|
dispatch.primaryLocation.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</InfoRow>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Linked Work Orders ({dispatch.workOrders.length})
|
||||||
|
</Typography>
|
||||||
|
{dispatch.workOrders.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">No work orders linked.</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>WO #</TableCell>
|
||||||
|
<TableCell>Title</TableCell>
|
||||||
|
<TableCell>Trade</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Location</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{dispatch.workOrders.map((wo) => (
|
||||||
|
<TableRow key={String(wo.workOrderId)}>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
|
||||||
|
>
|
||||||
|
{wo.internalWONumber || wo.workOrderId}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{wo.title}</TableCell>
|
||||||
|
<TableCell>{wo.trade}</TableCell>
|
||||||
|
<TableCell>{wo.status}</TableCell>
|
||||||
|
<TableCell>{wo.locationName}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
NTE Uplift History ({dispatch.upliftRequests.length})
|
||||||
|
</Typography>
|
||||||
|
{dispatch.upliftRequests.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{dispatch.upliftRequests.map((u) => (
|
||||||
|
<Box key={String(u.id)} className="rounded border border-border p-3">
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="body2" className="font-semibold">
|
||||||
|
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" label={u.status} />
|
||||||
|
</Stack>
|
||||||
|
{u.vendorReason && (
|
||||||
|
<Typography variant="body2" className="mt-1">
|
||||||
|
<strong>Reason:</strong> {u.vendorReason}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{u.status === "Pending" && (
|
||||||
|
<Stack direction="row" spacing={1} className="mt-2">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
onClick={() => {
|
||||||
|
setUpliftAction({ type: "approve", uplift: u });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setUpliftAction({ type: "deny", uplift: u });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||||
|
<DialogTitle>
|
||||||
|
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
className="mt-2"
|
||||||
|
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
required={upliftAction?.type === "deny"}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color={upliftAction?.type === "approve" ? "success" : "error"}
|
||||||
|
disabled={
|
||||||
|
upliftAction?.type === "deny"
|
||||||
|
? !note.trim() || denyUplift.isPending
|
||||||
|
: approveUplift.isPending
|
||||||
|
}
|
||||||
|
onClick={handleUpliftConfirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
src/app/(protected)/vendor-pos/index.tsx
Normal file
281
src/app/(protected)/vendor-pos/index.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
|
||||||
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
"",
|
||||||
|
"Sent",
|
||||||
|
"Acknowledged",
|
||||||
|
"In Progress",
|
||||||
|
"Completed",
|
||||||
|
"Verified",
|
||||||
|
"Cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatMoney(value: number | null): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return toDateInputValue(value) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorPosListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [vendorId, setVendorId] = useState("");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
status,
|
||||||
|
vendorId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
|
||||||
|
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
const vendors = vendorsData?.items ?? [];
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatus("");
|
||||||
|
setVendorId("");
|
||||||
|
setDateFrom("");
|
||||||
|
setDateTo("");
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: "stretch", md: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
Vendor POs
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||||
|
className="min-w-[280px]"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2} flexWrap="wrap">
|
||||||
|
<FormControl size="small" className="min-w-[140px]">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s || "all"} value={s}>
|
||||||
|
{s || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" className="min-w-[180px]">
|
||||||
|
<InputLabel>Vendor</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Vendor"
|
||||||
|
value={vendorId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVendorId(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<MenuItem key={String(v.id)} value={String(v.id)}>
|
||||||
|
{v.companyName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="From"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateFrom(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="To"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateTo(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<Button variant="text" onClick={clearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">PO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Dispatch #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Vendor</TableCell>
|
||||||
|
<TableCell className="font-semibold">WO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
<TableCell className="font-semibold">NTE</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell className="font-semibold">Uplifts</TableCell>
|
||||||
|
<TableCell className="font-semibold">Dispatched</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No vendor POs found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => navigate(`/vendor-pos/${row.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/vendor-pos/${row.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.poNumber || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.vendorName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.locationName || "—"}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || "—"} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.pendingUpliftCount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
label={`${row.pendingUpliftCount} pending`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal file
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
FormLabel,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||||
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
|
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
|
||||||
|
const defaultValues: VendorFormValues = {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VendorFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
||||||
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
|
const createVendor = useCreateVendor();
|
||||||
|
const updateVendor = useUpdateVendor();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VendorFormValues>({
|
||||||
|
resolver: zodResolver(vendorSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vendor && isEdit) {
|
||||||
|
reset({
|
||||||
|
companyName: vendor.companyName,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
email: vendor.email,
|
||||||
|
phone: vendor.phone,
|
||||||
|
address: vendor.address,
|
||||||
|
city: vendor.city,
|
||||||
|
state: vendor.state,
|
||||||
|
zip: vendor.zip,
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [vendor, isEdit, reset]);
|
||||||
|
|
||||||
|
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
|
||||||
|
const selectedTrades = (tradeSpecialties || "")
|
||||||
|
.split(",")
|
||||||
|
.map((trade) => trade.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const toggleTrade = (tradeValue: string) => {
|
||||||
|
const updated = selectedTrades.includes(tradeValue)
|
||||||
|
? selectedTrades.filter((trade) => trade !== tradeValue)
|
||||||
|
: [...selectedTrades, tradeValue];
|
||||||
|
setValue("tradeSpecialties", updated.join(", "));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateVendor.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createVendor.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load vendor"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
General Details
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Company Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(errors.companyName)}
|
||||||
|
helperText={errors.companyName?.message}
|
||||||
|
{...register("companyName")}
|
||||||
|
/>
|
||||||
|
<TextField label="Contact Name" fullWidth {...register("contactName")} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
type="email"
|
||||||
|
error={Boolean(errors.email)}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
<TextField label="Phone" fullWidth {...register("phone")} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Address
|
||||||
|
</Typography>
|
||||||
|
<TextField label="Address" fullWidth {...register("address")} />
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField label="City" fullWidth {...register("city")} />
|
||||||
|
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
|
||||||
|
<TextField label="Zip" fullWidth {...register("zip")} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||||
|
<Box className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{trades.map((trade) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={String(trade.id)}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTrades.includes(trade.value)}
|
||||||
|
onChange={() => toggleTrade(trade.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={trade.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isActive"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={field.value} onChange={field.onChange} />}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal file
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
useGenerateVendorPortalToken,
|
||||||
|
useRevokeVendorPortalToken,
|
||||||
|
useRotateVendorPortalToken,
|
||||||
|
useVendorPortalToken,
|
||||||
|
} from "@/domain/vendors/use-cases/use-vendor-portal-token";
|
||||||
|
|
||||||
|
interface VendorPortalTokenPanelProps {
|
||||||
|
vendorId: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps) {
|
||||||
|
const { data: tokenInfo, isLoading, isError } = useVendorPortalToken(vendorId);
|
||||||
|
const rotate = useRotateVendorPortalToken(vendorId);
|
||||||
|
const revoke = useRevokeVendorPortalToken(vendorId);
|
||||||
|
const generate = useGenerateVendorPortalToken(vendorId);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<"rotate" | "revoke" | null>(null);
|
||||||
|
|
||||||
|
const busy = rotate.isPending || revoke.isPending || generate.isPending;
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!tokenInfo?.portalUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(tokenInfo.portalUrl);
|
||||||
|
} catch {
|
||||||
|
// clipboard may be unavailable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-2">
|
||||||
|
Loading portal link…
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <Alert severity="error">Failed to load portal token</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenInfo) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Vendor Portal
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
|
||||||
|
No active portal link. Generate one so this vendor can access the portal.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Vendor Portal
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={tokenInfo.portalUrl}
|
||||||
|
slotProps={{ input: { readOnly: true } }}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" onClick={() => void copyUrl()}>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
|
||||||
|
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
|
||||||
|
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
|
||||||
|
{tokenInfo.lastUsedAt
|
||||||
|
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
|
||||||
|
: " · Never used"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
onClick={() => setConfirmAction("rotate")}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Rotate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setConfirmAction("revoke")}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
|
||||||
|
<DialogTitle>Rotate Portal Token</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Rotate the portal token? The previous link will stop working immediately.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
rotate.mutate();
|
||||||
|
setConfirmAction(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rotate
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
|
||||||
|
<DialogTitle>Revoke Portal Tokens</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Revoke all active portal tokens? The vendor will lose access until a new link is
|
||||||
|
generated.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
revoke.mutate();
|
||||||
|
setConfirmAction(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/vendors/edit/[id].tsx
vendored
Normal file
1
src/app/(protected)/vendors/edit/[id].tsx
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/vendor-form";
|
||||||
215
src/app/(protected)/vendors/index.tsx
vendored
Normal file
215
src/app/(protected)/vendors/index.tsx
vendored
Normal 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 "{deleteTarget?.companyName}"?
|
||||||
|
</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
1
src/app/(protected)/vendors/new.tsx
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/vendor-form";
|
||||||
479
src/app/(protected)/workorders/[id].tsx
Normal file
479
src/app/(protected)/workorders/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
355
src/app/(protected)/workorders/_components/work-order-form.tsx
Normal file
355
src/app/(protected)/workorders/_components/work-order-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/workorders/edit/[id].tsx
Normal file
1
src/app/(protected)/workorders/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/work-order-form";
|
||||||
310
src/app/(protected)/workorders/index.tsx
Normal file
310
src/app/(protected)/workorders/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/workorders/new.tsx
Normal file
1
src/app/(protected)/workorders/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/work-order-form";
|
||||||
21
src/app/404.tsx
Normal file
21
src/app/404.tsx
Normal 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
2
src/app/_app.tsx
Normal 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
Loading…
Add table
Reference in a new issue