From d2e0061b93ac5a382fe5ddc6c06adc4693080f6a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 11:48:47 -0300 Subject: [PATCH 01/24] chore(ci): add deploy pipeline, CDK scaffold, and board API docs --- docs/work-orders/board-search-api.md | 131 ++++++ infra/cdk/README.md | 4 +- infra/cdk/package-lock.json | 652 +++++++++++---------------- infra/cdk/package.json | 14 +- scripts/deploy-web.sh | 0 5 files changed, 412 insertions(+), 389 deletions(-) create mode 100644 docs/work-orders/board-search-api.md mode change 100755 => 100644 scripts/deploy-web.sh diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md new file mode 100644 index 00000000..14323dd0 --- /dev/null +++ b/docs/work-orders/board-search-api.md @@ -0,0 +1,131 @@ +# Work Orders Board & Search API + +Frontend integration contract for weekly board and advanced search filters. + +## Base + +| Item | Valor | +| ------------ | ------------------------------------------- | +| Prefixo | `/api/workorders` (alias: `/api/WorkOrder`) | +| Auth | `Authorization: Bearer {token}` (JWT) | +| Content-Type | Não necessário (GET) | + +Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`. + +## Quando usar qual endpoint + +| Modo FE | Endpoint | Quando | +| --------------------------------------- | -------------------------- | ------------------------------------------------ | +| Barra principal (`advApplied === null`) | `GET /board` | Semana + dispatchers + tipo segment + search | +| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação | +| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers | + +**Não misture parâmetros:** `weekStart`/`weekEnd` só no `/board`; `datePreset` só no `/search`. + +## Query builders (FE) + +- [`board-query-params.ts`](../../src/domain/work-orders/utils/board-query-params.ts) — `toBoardQueryParams`, `toBoardSearchQueryParams` +- Arrays na query: repetição simples (`types=2&types=6`), via `toUrlSearchParams` em `work-orders-api.ts` + +## Barra → `GET /board` + +| FE state | API param | Regra | +| --------------- | ---------------------- | ----------------------------------------------------------- | +| `search` | `search` | Enviar só se `length >= 2`; com 1 char, filtrar client-side | +| `weekMonday` | `weekStart` | ISO `YYYY-MM-DD` (segunda) | +| — | `weekEnd` | `weekStart + 4` (sexta) | +| `dispatcherIds` | `dispatchers` | `__unassigned` → `__unassigned__` | +| "My WOs" | `dispatchers={userId}` | Equivalente a `myWorkOrders=true` | +| `typeFilter` | `types` | PM=2, Emergency=3, Reactive=6, Add-On=7, **Overdue=99** | + +## Advanced → `GET /board/search` + +| FE state | API param | Regra | +| ---------------- | --------------------- | ------------------------------------------ | +| `page` | `page` | **0-based** (primeira página = `0`) | +| `pageSize` | `pageSize` | Default `100`, max `100` | +| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` | +| `sortDir` | `sortDir` | `asc` \| `desc` | +| `search` | `search` | `>= 2` chars | +| `dateRange` | `datePreset` | PascalCase (ver abaixo) | +| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` | +| `sites` (IDs UI) | `sites` | **Site codes**, não location ID | +| `types` | `types` | Integers; Overdue → `99` | +| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` | +| `statuses` | `statuses` | Integers 1–10 | +| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) | +| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) | +| `docs` | `docStatuses` | No=2, Yes=1, NN=3 | + +### `datePreset` + +| FE key | API value | +| --------------- | ------------- | +| `this-week` | `ThisWeek` | +| `last-week` | `LastWeek` | +| `this-month` | `ThisMonth` | +| `last-3-months` | `Last3Months` | +| `next-week` | `NextWeek` | +| `next-month` | `NextMonth` | +| `custom` | `Custom` | + +### `types` — WorkOrderType / overdue + +| Label FE | API | +| --------- | ------------------------- | +| PM | `2` | +| Emergency | `3` | +| Reactive | `6` | +| Add-On | `7` | +| Overdue | `99` (filtra `isPastDue`) | + +### `statuses` — LifecycleStatus + +Incomplete=1, Pending=2, Scheduled=3, En Route=4, On Site=5, In Progress=6, Completed=7, Rescheduled=8, Canceled=9, Pending Quote=10. + +### `docStatuses` + +Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3. + +## Respostas + +### `/board` + +```json +{ + "weekStart": "2026-07-14", + "weekEnd": "2026-07-18", + "counts": { "total": 42, "returned": 10 }, + "scheduled": [], + "unscheduled": [] +} +``` + +### `/board/search` + +```json +{ + "items": [], + "totalCount": 150, + "page": 0, + "pageSize": 100, + "totalPages": 2, + "hasPrevious": false, + "hasNext": true +} +``` + +## Erros HTTP + +| Situação | Status | +| --------------------------------------------------- | ------ | +| `weekEnd < weekStart` ou janela > 7 dias | 400 | +| `datePreset=Custom` sem datas / `dateTo < dateFrom` | 400 | +| `sortBy` inválido | 400 | +| Sem token / expirado | 401 | + +Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`. + +## Validação UI + +O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário. diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 8ecf1cd0..37384c2e 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -157,5 +157,5 @@ backend must also allow CORS from each frontend origin. - **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still deploys (matches the org's push-time-CD model). Gating deploy on CI is a follow-up, not part of enabling CICD. -- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses - lockfileVersion 3, matching the Node 24 / npm 11 CI environment. +- **Local npm is pinned to v6**; the committed `package-lock.json` is + lockfileVersion 1. CI (Node 24 / npm 11) reads it fine via `npm ci`. diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json index c5730f17..e0d1123c 100644 --- a/infra/cdk/package-lock.json +++ b/infra/cdk/package-lock.json @@ -1,221 +1,134 @@ { "name": "shoc-frontend-infra", "version": "0.1.0", - "lockfileVersion": 3, + "lockfileVersion": 1, "requires": true, - "packages": { - "": { - "name": "shoc-frontend-infra", - "version": "0.1.0", - "dependencies": { - "aws-cdk-lib": "^2.261.0", - "constructs": "^10.4.2" - }, - "bin": { - "app": "bin/app.ts" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", - "ts-node": "^10.9.2", - "typescript": "~6.0.3" - }, - "engines": { - "node": ">=22.22.1" - } - }, - "node_modules/@aws-cdk/asset-awscli-v1": { + "dependencies": { + "@aws-cdk/asset-awscli-v1": { "version": "2.2.282", "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", - "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", - "license": "Apache-2.0" + "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==" }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "@aws-cdk/asset-node-proxy-agent-v6": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", - "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", - "license": "Apache-2.0" + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==" }, - "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "54.9.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz", - "integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "license": "Apache-2.0", - "dependencies": { + "@aws-cdk/cloud-assembly-schema": { + "version": "54.5.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz", + "integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==", + "requires": { "jsonschema": "^1.5.0", - "semver": "^7.8.5" + "semver": "^7.8.4" }, - "engines": { - "node": ">= 18.0.0" + "dependencies": { + "jsonschema": { + "version": "1.5.0", + "bundled": true + }, + "semver": { + "version": "7.8.4", + "bundled": true + } } }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.8.5", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@cspotcode/source-map-support": { + "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" } }, - "node_modules/@jridgewell/resolve-uri": { + "@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "dev": true }, - "node_modules/@jridgewell/sourcemap-codec": { + "@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@jridgewell/trace-mapping": { + "@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@tsconfig/node10": { + "@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@tsconfig/node12": { + "@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@tsconfig/node14": { + "@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@tsconfig/node16": { + "@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" + "requires": { + "undici-types": "~6.21.0" } }, - "node_modules/acorn": { + "acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "dev": true }, - "node_modules/acorn-walk": { + "acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" } }, - "node_modules/arg": { + "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/aws-cdk": { - "version": "2.1130.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz", - "integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 18.0.0" - } + "aws-cdk": { + "version": "2.1128.1", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz", + "integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==", + "dev": true }, - "node_modules/aws-cdk-lib": { - "version": "2.261.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz", - "integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==", - "bundleDependencies": [ - "@balena/dockerignore", - "@aws-cdk/cloud-assembly-api", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "yaml", - "mime-types" - ], - "license": "Apache-2.0", - "dependencies": { + "aws-cdk-lib": { + "version": "2.260.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz", + "integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==", + "requires": { "@aws-cdk/asset-awscli-v1": "2.2.282", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", "@aws-cdk/cloud-assembly-api": "^2.2.5", @@ -229,212 +142,231 @@ "minimatch": "^10.2.5", "punycode": "^2.3.1", "semver": "^7.8.1", + "table": "^6.9.0", "yaml": "1.10.3" }, - "engines": { - "node": ">= 20.0.0" - }, - "peerDependencies": { - "constructs": "^10.5.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.5", - "inBundle": true, - "license": "Apache-2.0", "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.0" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.28.0" + "@aws-cdk/cloud-assembly-api": { + "version": "2.2.5", + "bundled": true, + "requires": { + "jsonschema": "^1.5.0", + "semver": "^7.8.0" + } + }, + "@balena/dockerignore": { + "version": "1.0.2", + "bundled": true + }, + "ajv": { + "version": "8.20.0", + "bundled": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "bundled": true + }, + "ansi-styles": { + "version": "4.3.0", + "bundled": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "astral-regex": { + "version": "2.0.0", + "bundled": true + }, + "balanced-match": { + "version": "4.0.4", + "bundled": true + }, + "brace-expansion": { + "version": "5.0.6", + "bundled": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "case": { + "version": "1.6.3", + "bundled": true + }, + "color-convert": { + "version": "2.0.1", + "bundled": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "bundled": true + }, + "emoji-regex": { + "version": "8.0.0", + "bundled": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "bundled": true + }, + "fast-uri": { + "version": "3.1.2", + "bundled": true + }, + "fs-extra": { + "version": "11.3.5", + "bundled": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "graceful-fs": { + "version": "4.2.11", + "bundled": true + }, + "ignore": { + "version": "5.3.2", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "bundled": true + }, + "json-schema-traverse": { + "version": "1.0.0", + "bundled": true + }, + "jsonfile": { + "version": "6.2.1", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonschema": { + "version": "1.5.0", + "bundled": true + }, + "lodash.truncate": { + "version": "4.4.2", + "bundled": true + }, + "mime-db": { + "version": "1.52.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.35", + "bundled": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "10.2.5", + "bundled": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "punycode": { + "version": "2.3.1", + "bundled": true + }, + "require-from-string": { + "version": "2.0.2", + "bundled": true + }, + "semver": { + "version": "7.8.1", + "bundled": true + }, + "slice-ansi": { + "version": "4.0.0", + "bundled": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "string-width": { + "version": "4.2.3", + "bundled": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "bundled": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "table": { + "version": "6.9.0", + "bundled": true, + "requires": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + } + }, + "universalify": { + "version": "2.0.1", + "bundled": true + }, + "yaml": { + "version": "1.10.3", + "bundled": true + } } }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "4.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.8.1", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/constructs": { + "constructs": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", - "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "license": "Apache-2.0" + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==" }, - "node_modules/create-require": { + "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/diff": { + "diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } + "dev": true }, - "node_modules/make-error": { + "make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" + "dev": true }, - "node_modules/ts-node": { + "ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", @@ -448,67 +380,31 @@ "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true }, - "node_modules/v8-compile-cache-lib": { + "v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/yn": { + "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "dev": true } } } diff --git a/infra/cdk/package.json b/infra/cdk/package.json index e773f265..1ca0dfd1 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -6,9 +6,6 @@ "bin": { "app": "bin/app.ts" }, - "engines": { - "node": ">=22.22.1" - }, "scripts": { "build": "tsc", "synth": "cdk synth", @@ -16,14 +13,13 @@ "deploy": "cdk deploy" }, "devDependencies": { - "@types/node": "^24.13.3", - "aws-cdk": "^2.1130.0", + "@types/node": "^22.15.30", + "aws-cdk": "^2.1021.0", "ts-node": "^10.9.2", - "typescript": "~6.0.3" + "typescript": "~5.8.3" }, "dependencies": { - "aws-cdk-lib": "^2.261.0", + "aws-cdk-lib": "^2.220.0", "constructs": "^10.4.2" - }, - "packageManager": "npm@11.16.0" + } } diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh old mode 100755 new mode 100644 From 042b9db2ac7785ec0935b079a0c702f06f79f358 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:17:21 -0400 Subject: [PATCH 02/24] chore: correct Sea Haven branding and rewrite README (#25) * fix: correct Sea Haven branding in page title and wordmark * chore: use correct company name in docs and comments * docs: rewrite README * docs: add README badge block per github-standards * fix: update command syntax in deployment instructions * fix: replace template PWA icons and manifest branding * test: assert PWA manifest branding --- README.md | 230 ++++++++++++++------ config/pwa-manifest.test.ts | 33 +++ docs/ARCHITECTURE_PLAN.md | 2 +- docs/DESIGN_SYSTEM.md | 8 +- docs/DESIGN_SYSTEM_MIGRATION.md | 12 +- docs/FEATURE_PRIORITIZATION.md | 2 +- docs/UI_DOCUMENTATION.md | 6 +- index.html | 6 +- infra/cdk/README.md | 2 +- infra/cdk/lib/frontend-stack.ts | 2 +- infra/cdk/package.json | 2 +- public/favicon.ico | Bin 3870 -> 2882 bytes public/logo192.png | Bin 5347 -> 4190 bytes public/logo512.png | Bin 9664 -> 5406 bytes public/manifest.json | 8 +- src/components/common/brand-lockup.tsx | 2 +- src/components/domain/status-badge-utils.ts | 2 +- src/styles/fonts.css | 2 +- src/styles/theme.css | 4 +- tsconfig.node.json | 3 +- 20 files changed, 230 insertions(+), 96 deletions(-) create mode 100644 config/pwa-manifest.test.ts diff --git a/README.md b/README.md index c58d0fcd..e380b5df 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,180 @@ -# SeaHaven Admin +# SHOC Frontend (`shoc-frontend-new`) -Vite + React SPA for SeaHaven facility management (work orders, vendor portal, uplifts, and related admin features). +[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml) +[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml) +![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) +![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white) +![AWS CDK](https://img.shields.io/badge/AWS_CDK-FF9900?logo=amazonwebservices&logoColor=white) -## Requirements +Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor +portal, uplifts, and related admin features. This is the selective rebuild of +the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions +documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md). -- Node.js 22.22.1+ -- npm 11.16.0 - -## Setup - -```bash -npm ci -cp .env.example .env -``` - -### Environment variables - -| Variable | Description | Default | -| ----------------- | ----------------------------------------------- | ----------------------- | -| `VITE_API_URL` | Ky API prefix (baked into the production build) | `/api` | -| `VITE_API_TARGET` | Dev proxy target for `/api` (Vite only) | `http://localhost:5141` | - -`VITE_API_URL` is the full API base prefix. Route paths in `API_PATHS` do **not** include `/api` — only this variable supplies it. - -- **Development:** use `VITE_API_URL=/api`. The Vite dev server proxies `/api` to `VITE_API_TARGET`. -- **Production:** use an absolute URL that **ends with `/api`** (e.g. `https://api.example.com/api`). Absolute URLs without `/api` will produce broken API requests. - -See [`.env.example`](.env.example) and [`.env.production`](.env.production) for reference values. - -## Scripts - -| Command | Description | -| ---------------------- | ------------------------------------------ | -| `npm run dev` | Start Vite dev server on port 3000 | -| `npm run build` | Type-check and production build to `dist/` | -| `npm run preview` | Preview production build locally | -| `npm test` | Run Vitest unit tests | -| `npm run test:watch` | Run Vitest in watch mode | -| `npm run lint` | ESLint | -| `npm run lint:fix` | ESLint with auto-fix | -| `npm run format` | Prettier write | -| `npm run format:check` | Prettier check (used in CI) | +- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new` +- **Hosted at:** (dev environment; the only environment today) +- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend` ## Architecture -New code lives under `src/domain/`, `src/app/`, and `src/api/` following the IrisLoan conventions documented in `docs/ARCHITECTURE_PLAN.md`. +Static SPA hosting on AWS, provisioned by a CDK app local to this repo +([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/` +from a private S3 bucket; the SPA calls the backend directly over HTTPS at +`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS). -## CI +```mermaid +graph LR + U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront] + CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev] + CF -.->|viewer-request fn| FN[SPA rewrite → /index.html] + U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API] + GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev] + ROLE -->|cdk deploy + s3 sync + invalidation| S3 +``` -GitHub Actions workflow (`.github/workflows/ci.yml`) runs on push and pull requests: +Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack +Query, React Router (via `@generouted/react-router`), React Hook Form + Zod, +Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the +architecture plan for the keep/discard migration matrix). -1. `npm run format:check` -2. `npm run lint` -3. `npm run build` -4. `npm test` +## AWS Resources -Local pre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files. +Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region +`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts). -## Deployment (CI/CD) +| Resource | Name | Purpose | +| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) | +| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` | +| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) | +| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | +| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront | -The app is hosted on **AWS S3 + CloudFront**, provisioned by an **AWS CDK** app -local to this repo ([`infra/cdk/`](infra/cdk/README.md)). Deployment runs -through the org's reusable GitHub Actions workflow via **OIDC** (no stored AWS -keys): +No Lambdas, queues, or databases — this stack is static hosting only. -- Push to `dev` → `.github/workflows/deploy.yml` calls the org reusable - `cd-cdk.yaml`, which runs `cdk deploy` (infra) then `scripts/deploy-web.sh` - (builds the SPA, syncs `dist/` to S3, invalidates CloudFront). -- Served on the custom domain `dev.seahaven.com`; the SPA calls the backend - directly over HTTPS at `VITE_API_URL` (`https://api.dev.seahaven.com/api`, - cross-origin — the backend allows CORS). `VITE_API_URL` is baked into the - build, so it is per-environment. -- First-time provisioning (OIDC provider, CDK bootstrap, first local deploy, the - `AWS_DEPLOY_ROLE_ARN` secret) is a one-time admin task — see - [`infra/cdk/README.md`](infra/cdk/README.md). +## Configuration -## Development proxy +### Secrets -During `npm run dev`, requests to `/api` are proxied to `VITE_API_TARGET` (see `vite.config.ts`). +No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions +repo secret**: + +| Secret | Purpose | +| --------------------- | ----------------------------------------------------------------------------------- | +| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow | + +### Environment variables (build-time, `VITE_*`) + +| Variable | Description | Dev value | +| ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- | +| `VITE_API_URL` | Ky API base prefix, **baked into the build** at `vite build` | `/api` (dev server) / `https://api.dev.seahaven.com/api` (production build) | +| `VITE_API_TARGET` | Dev-proxy target for `/api` (Vite dev server only) | `http://localhost:5141` | + +`VITE_API_URL` supplies the full API prefix — route paths in `API_PATHS` do +**not** include `/api`. Absolute values **must end with `/api`**; +`vite.config.ts` enforces this via `config/api-url-contract.ts` and fails the +build otherwise. See [`.env.example`](.env.example), +[`.env.development`](.env.development), and [`.env.production`](.env.production). + +CDK context (domain, certificate ARN, hosted zone) lives in +[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags. + +## Local Development + +Requirements: Node.js ≥ 22.22.1 (CI/CD run Node 24), npm 11.16.0 (pinned via +`packageManager`). + +```bash +npm ci +cp .env.example .env # then set VITE_API_URL=/api for local dev +npm run dev # Vite dev server on port 3000, proxies /api → VITE_API_TARGET +``` + +The dev proxy expects the `shoc-backend` API at `http://localhost:5141`; +override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use +the deployed dev API). + +| Command | Description | +| ------------------------------------------ | ----------------------------------------------------- | +| `npm run dev` | Start Vite dev server on port 3000 | +| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` | +| `npm run preview` | Preview the production build locally | +| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) | +| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) | +| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) | +| `npm run format` / `npm run format:check` | Prettier (write / check) | + +Husky + lint-staged run ESLint and Prettier on staged files at commit; +commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or +`npm run build`) before pushing to catch type errors early. + +## Contributing + +- Branch from `dev` with a kebab-case description and a prefix matching the + work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/` + (e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`). +- Commit messages follow + [Conventional Commits](https://www.conventionalcommits.org) — commitlint + rejects anything else at commit time. +- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs + a green CI run and an approving review from a code owner + (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. + Merged branches are deleted automatically. +- Promotion flow: `feature/* → dev` (auto-deployed and verified on + `dev.seahaven.com`) `→ main` (production promotion — no prod environment + exists yet). + +## Deployment + +CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only): + +- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push + and PRs to `main`/`dev`, calls + `Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): + format check, lint, build, tests. +- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on + push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which + runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`) + and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`, + `aws s3 sync dist/` (hashed assets immutable, `index.html` never cached), + CloudFront invalidation. Both run as the OIDC deploy role. + +One-time provisioning (OIDC provider, CDK bootstrap, first local deploy, +setting `AWS_DEPLOY_ROLE_ARN`) is documented in +[`infra/cdk/README.md`](infra/cdk/README.md). + +Manual deploy (emergency/reference only — needs credentials for the +external-dev AWS account; the normal path is push to `dev`): + +```bash +(cd infra/cdk && npx cdk deploy) +STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh +``` + +## Operations + +- **Verify:** open after a green **Deploy** run in + the Actions tab; confirm a deep link (e.g. a work-orders route) loads + directly and API calls succeed. +- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There + are no CloudWatch application logs — the stack is static hosting; runtime + errors surface in the browser and on the backend API's side. +- **Common failure modes:** + - _Stale content after deploy_ — the CloudFront invalidation step failed or + is still propagating; re-run the Deploy workflow or invalidate `/*` manually. + - _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev` + on this repo; deploys from other branches/repos are rejected by design. + - _Broken API requests after a build_ — `VITE_API_URL` missing the `/api` + suffix or carrying the wrong environment's host (it is baked in at build time). + - _CORS errors_ — the backend must allow the frontend origin; CloudFront does + not proxy `/api`. +- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still + deploys (matches the org's push-time-CD model; gating deploy on CI is known + follow-up work). + +## Documentation + +- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md) +- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md); + design system and UI docs under [`docs/`](docs/) diff --git a/config/pwa-manifest.test.ts b/config/pwa-manifest.test.ts new file mode 100644 index 00000000..a509de14 --- /dev/null +++ b/config/pwa-manifest.test.ts @@ -0,0 +1,33 @@ +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.resolve(__dirname, ".."); +const manifest = JSON.parse(readFileSync(path.join(root, "public/manifest.json"), "utf-8")) as { + name: string; + short_name: string; + theme_color: string; + icons: Array<{ src: string }>; +}; +const indexHtml = readFileSync(path.join(root, "index.html"), "utf-8"); + +describe("PWA install metadata", () => { + it("identifies the app as Sea Haven, not template branding", () => { + expect(manifest.name).toContain("Sea Haven"); + expect(manifest.short_name).not.toBe("React App"); + expect(manifest.name).not.toContain("Create React App"); + }); + + it("uses the design-system theme color, matching index.html", () => { + expect(manifest.theme_color).toBe("#1c75bc"); + expect(indexHtml).toContain(``); + }); + + it("only references icon files that exist in public/", () => { + for (const icon of manifest.icons) { + expect(existsSync(path.join(root, "public", icon.src)), `missing public/${icon.src}`).toBe( + true, + ); + } + }); +}); diff --git a/docs/ARCHITECTURE_PLAN.md b/docs/ARCHITECTURE_PLAN.md index 57a583b7..1e5d44ee 100644 --- a/docs/ARCHITECTURE_PLAN.md +++ b/docs/ARCHITECTURE_PLAN.md @@ -1,4 +1,4 @@ -# Architecture Plan — SeaHaven (Selective Rebuild) +# Architecture Plan — Sea Haven (Selective Rebuild) > Aligned with IrisLoan.Admin · Vite SPA + React Router · **no Next.js** · Tailwind + MUI diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 51a2df76..28d3996a 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1,4 +1,4 @@ -# SeaHaven Design System +# Sea Haven Design System Visual foundation for **seaheven.front**, aligned with [seahaven.desing](https://github.com) tokens and IrisLoan.Admin integration patterns (centralized CSS, `@theme inline`, `cn()`). @@ -83,7 +83,7 @@ MUI typography variants in `src/lib/theme/mui-theme.ts` mirror this hierarchy. ### 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`). +MUI components inherit Sea Haven styling via `mui-theme.ts` overrides (`Button`, `TextField`, `Table`, `Chip`, `Paper`, `Dialog`, `Alert`). ### Prefer semantic Tailwind in layout shells @@ -119,9 +119,9 @@ import { cn } from "@/lib/utils"; | `StatusBadge` / `WorkOrderStatusBadge` | `components/domain/status-badge.tsx` | | `DocBadge` | `components/domain/doc-badge.tsx` | -## Legacy → Seahaven mapping +## Legacy → Sea Haven mapping -| Legacy | Seahaven | Consumption | +| Legacy | Sea Haven | Consumption | | ------------------------- | ------------------------------ | -------------------------------- | | `#0c4f6f` / `--blue-main` | `--primary` (`#1c75bc`) | MUI `primary.main`, `bg-primary` | | `#083a52` / `--blue-dark` | `--primary-hover` | Button hover | diff --git a/docs/DESIGN_SYSTEM_MIGRATION.md b/docs/DESIGN_SYSTEM_MIGRATION.md index 3446239e..21cfa639 100644 --- a/docs/DESIGN_SYSTEM_MIGRATION.md +++ b/docs/DESIGN_SYSTEM_MIGRATION.md @@ -1,12 +1,12 @@ # Design System Migration Log -Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens. Strategy: **MUI-only**. +Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Sea Haven DS tokens. Strategy: **MUI-only**. ## Phase 1 — Foundation | File | Action | Reason | | --------------------------- | ---------- | --------------------------------------------------------------------- | -| `src/styles/theme.css` | Created | SoT: 131+ Seahaven tokens + layout/typography/elevation extensions | +| `src/styles/theme.css` | Created | SoT: 131+ Sea Haven tokens + layout/typography/elevation extensions | | `src/styles/fonts.css` | Created | Montserrat, DM Sans, JetBrains Mono via `@fontsource` | | `src/styles/typography.css` | Created | Base html/body/heading/caption/label styles | | `src/styles/motion.css` | Created | Duration and easing tokens | @@ -29,7 +29,7 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens | `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/app/v/_components/vendor-portal.css` | Modified | Re-tokenized with Sea Haven CSS vars | | `src/index.css` | Deleted | ~487 lines migrated to tokens + TSX shell | | `src/components/Sidebar.js` | Deleted | Replaced by `app-sidebar.tsx` | | `src/components/Topbar.js` | Deleted | Replaced by `app-topbar.tsx` | @@ -59,7 +59,7 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens | `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-utils.ts` | Modified | Bootstrap hex → Sea Haven CSS vars | | `src/components/common/calendar/calendar-action-bar.tsx` | Modified | Semantic surface classes | | `src/components/common/calendar/event-calendar.tsx` | Modified | Semantic surface classes | | `src/components/common/signature-capture.tsx` | Modified | Canvas colors from CSS vars | @@ -79,8 +79,8 @@ Migration from legacy CSS (`index.css`, `#0c4f6f` palette) to Seahaven DS tokens - 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 +- Sidebar: `#fbfbfb` → `#f6f8fb` with Sea Haven active/hover states +- Header: flat blue gradient → Sea Haven multi-stop gradient ## Not migrated (intentional) diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md index 28bbb432..aa52e770 100644 --- a/docs/FEATURE_PRIORITIZATION.md +++ b/docs/FEATURE_PRIORITIZATION.md @@ -1,4 +1,4 @@ -# Feature Prioritization Matrix — SeaHaven +# Feature Prioritization Matrix — Sea Haven > Source of truth for legacy → `domain/` + `app/` migration order. > Machine-readable config: [`src/config/feature-priorities.ts`](../src/config/feature-priorities.ts). diff --git a/docs/UI_DOCUMENTATION.md b/docs/UI_DOCUMENTATION.md index c7d7b7b2..2d836181 100644 --- a/docs/UI_DOCUMENTATION.md +++ b/docs/UI_DOCUMENTATION.md @@ -1,4 +1,4 @@ -# SeaHaven UI - Complete Architecture Documentation +# Sea Haven UI - Complete Architecture Documentation > Modern React architecture with Redux Toolkit, React Query, and CSS Modules @@ -55,7 +55,7 @@ │ 5. BACKEND │ │ │ │ REST API │ -│ (SeaHaven Backend) │ +│ (Sea Haven Backend) │ └─────────────────────────────────────────────────────────────┘ ``` @@ -539,6 +539,6 @@ const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH); --- -**Welcome to the modern SeaHaven UI! 🎊** +**Welcome to the modern Sea Haven UI! 🎊** _Last updated: 2026_ diff --git a/index.html b/index.html index 6f00ee89..2e7fb09f 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,8 @@ - - + + @@ -18,7 +18,7 @@ referrerpolicy="no-referrer" /> - Seahaven - Officetrax + Sea Haven - Work Orders diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 8ecf1cd0..e876a0c7 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -1,4 +1,4 @@ -# Infrastructure & CI/CD — SeaHaven SHOC frontend +# Infrastructure & CI/CD — Sea Haven SHOC frontend AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo, deployed through the org's **reusable** GitHub Actions workflow. diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index be3a558c..be7a4681 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -38,7 +38,7 @@ export interface FrontendStackProps extends StackProps { } /** - * Static SPA hosting for the SeaHaven SHOC frontend: + * Static SPA hosting for the Sea Haven SHOC frontend: * - private S3 bucket (no public access; CloudFront reads it via OAC) * - CloudFront distribution (HTTPS, SPA deep-link fallback) * - a GitHub Actions OIDC deploy role diff --git a/infra/cdk/package.json b/infra/cdk/package.json index e773f265..b490e706 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -2,7 +2,7 @@ "name": "shoc-frontend-infra", "version": "0.1.0", "private": true, - "description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the SeaHaven SHOC frontend.", + "description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.", "bin": { "app": "bin/app.ts" }, diff --git a/public/favicon.ico b/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..95e736b553693760b6f169dfa0f543d3f1525c40 100644 GIT binary patch literal 2882 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 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/public/logo192.png b/public/logo192.png index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..20a794a107cac94e3287c7e4f362a1f264c1af10 100644 GIT binary patch literal 4190 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 literal 5347 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 V^trND@Z1wP`J20QonzS5{{zv9&6EHD literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/public/manifest.json b/public/manifest.json index 080d6c77..f058e498 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,10 +1,10 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Work Orders", + "name": "Sea Haven - Work Orders", "icons": [ { "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", + "sizes": "64x64 32x32 16x16", "type": "image/x-icon" }, { @@ -20,6 +20,6 @@ ], "start_url": ".", "display": "standalone", - "theme_color": "#000000", + "theme_color": "#1c75bc", "background_color": "#ffffff" } diff --git a/src/components/common/brand-lockup.tsx b/src/components/common/brand-lockup.tsx index 85a5a517..326d99ff 100644 --- a/src/components/common/brand-lockup.tsx +++ b/src/components/common/brand-lockup.tsx @@ -37,7 +37,7 @@ export function BrandWordmark({ size = "default", className, ...props }: BrandWo size === "compact" ? "text-md" : "text-[16px]", )} > - SEAHAVEN + SEA HAVEN
= { open: "incomplete", incomplete: "incomplete", diff --git a/src/styles/fonts.css b/src/styles/fonts.css index eb862666..2163a14d 100644 --- a/src/styles/fonts.css +++ b/src/styles/fonts.css @@ -1,4 +1,4 @@ -/* Seahaven DS — self-hosted via @fontsource (matches theme.css stacks) */ +/* Sea Haven DS — self-hosted via @fontsource (matches theme.css stacks) */ @import "@fontsource/montserrat/600.css"; @import "@fontsource/montserrat/700.css"; diff --git a/src/styles/theme.css b/src/styles/theme.css index 0b950a4e..121225f3 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -45,7 +45,7 @@ --shadow-lg: 0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08); --shadow-brand-mark: 0 2px 6px -1px rgba(28, 117, 188, 0.45); - /* ── SHADCN BASE (Seahaven DS) ──────────────────────────────────── */ + /* ── SHADCN BASE (Sea Haven DS) ──────────────────────────────────── */ --background: #ffffff; --foreground: #262262; --placeholder-foreground: color-mix(in srgb, var(--foreground) 50%, transparent); @@ -79,7 +79,7 @@ --chart-4: #7c3aed; --chart-5: #dc2626; - /* ── SIDEBAR (light surface — Seahaven DS) ──────────────────────── */ + /* ── SIDEBAR (light surface — Sea Haven DS) ──────────────────────── */ --sidebar: #f6f8fb; --sidebar-bg: #f6f8fb; --sidebar-foreground: #262262; diff --git a/tsconfig.node.json b/tsconfig.node.json index 3e43f464..5fc395e4 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -20,6 +20,7 @@ "vite.config.ts", "vitest.config.ts", "config/api-url-contract.ts", - "config/api-url-contract.test.ts" + "config/api-url-contract.test.ts", + "config/pwa-manifest.test.ts" ] } From 4beefba71937df8fb1ce5bcfb386e132da66df65 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:47:43 -0300 Subject: [PATCH 03/24] fix(work-orders): align consumers with domain-core types --- src/app/(protected)/workorders/[id].tsx | 5 +- .../_components/dispatch-create-modal.tsx | 6 +- src/app/(protected)/workorders/index.tsx | 14 +- .../mocks/dispatcher-filter-mock-data.ts | 45 ++ .../mocks/is-wo-table-mock-enabled.ts | 10 + .../mocks/is-wo-wizard-mock-enabled.ts | 10 + .../work-orders/mocks/wo-table-mock-data.ts | 521 ++++++++++++++++++ .../work-orders/mocks/wo-wizard-mock-data.ts | 158 ++++++ .../utils/advanced-filters-utils.ts | 240 ++++++++ .../work-orders/utils/list-week-utils.ts | 68 +++ .../work-orders/utils/table-day-utils.ts | 46 ++ .../work-orders/utils/table-filter-utils.ts | 166 ++++++ .../work-orders/utils/wizard-date-utils.ts | 148 +++++ 13 files changed, 1425 insertions(+), 12 deletions(-) create mode 100644 src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts create mode 100644 src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts create mode 100644 src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts create mode 100644 src/domain/work-orders/mocks/wo-table-mock-data.ts create mode 100644 src/domain/work-orders/mocks/wo-wizard-mock-data.ts create mode 100644 src/domain/work-orders/utils/advanced-filters-utils.ts create mode 100644 src/domain/work-orders/utils/list-week-utils.ts create mode 100644 src/domain/work-orders/utils/table-day-utils.ts create mode 100644 src/domain/work-orders/utils/table-filter-utils.ts create mode 100644 src/domain/work-orders/utils/wizard-date-utils.ts diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 55a32726..a282040c 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -192,7 +192,10 @@ export default function WorkOrderViewPage() { } commentMutation.mutate( - { text: commentText.trim(), commentType: activeTab }, + { + text: commentText.trim(), + commentType: activeTab === "customer" ? "customer" : "internal", + }, { onSuccess: () => setCommentText("") }, ); }; diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx index 1595d50a..5dd6952a 100644 --- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx @@ -96,7 +96,7 @@ export function DispatchCreateModal({ .filter( (item) => !woSearch || - item.number.includes(woSearch) || + item.woNumber.includes(woSearch) || item.title.toLowerCase().includes(woSearch.toLowerCase()), ) .slice(0, 10), @@ -180,13 +180,13 @@ export function DispatchCreateModal({ onClick={() => { setSelectedWOs((prev) => [ ...prev, - { id: item.id, number: item.number, title: item.title }, + { id: item.id, number: item.woNumber, title: item.title }, ]); setWoSearch(""); }} > - {item.number} — {item.title} + {item.woNumber} — {item.title} ))} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index c33f1a2e..5fabb151 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -28,7 +28,7 @@ import { Typography, } from "@mui/material"; import { useDebounce } from "@/hooks/use-debounce"; -import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderUsers, useWorkOrdersList, @@ -271,7 +271,7 @@ export default function WorkOrdersListPage() { ) : ( - rows.map((row: WorkOrderListItem) => ( + rows.map((row: WorkOrderTableRow) => ( {row.title} @@ -290,12 +290,10 @@ export default function WorkOrdersListPage() { - {formatDateTime(row.createdDate)} - - {row.assignedTo || "Unassigned"} - - {formatDateTime(row.lastUpdated)} + {formatDateTime(row.scheduledOn)} + {row.dispatcherName || "Unassigned"} + {formatDateTime(row.dueDate)} [dispatcher.id, dispatcher.color]), +) as Record; + +const FALLBACK_AVATAR_COLORS = [ + "#F59E0B", + "#0D9488", + "#6366F1", + "#F43F5E", + "#7C3AED", + "#0EA5E9", + "#16A34A", + "#EA580C", +]; + +function paletteColorFromKey(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i += 1) { + hash = key.charCodeAt(i) + ((hash << 5) - hash); + } + return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length]; +} + +export function getDispatcherAvatarColor(user: WorkOrderUser): string { + return ( + MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ?? + paletteColorFromKey(String(user.id || user.name)) + ); +} diff --git a/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts new file mode 100644 index 00000000..5daac141 --- /dev/null +++ b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts @@ -0,0 +1,10 @@ +import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass"; + +/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */ +export function isWoTableMockEnabled(): boolean { + return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true"; +} + +export function shouldUseMockWorkOrderDetail(id: string | number): boolean { + return isWoTableMockEnabled() && String(id).startsWith("mock-wo-"); +} diff --git a/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts new file mode 100644 index 00000000..9420dd2e --- /dev/null +++ b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts @@ -0,0 +1,10 @@ +import { env } from "@/lib/env"; +import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass"; + +export function isWoWizardMockEnabled(): boolean { + return env.isDev; +} + +export function shouldUseMockWoWizardCreate(locationId: string): boolean { + return isAuthBypassEnabled() || locationId.startsWith("mock-"); +} diff --git a/src/domain/work-orders/mocks/wo-table-mock-data.ts b/src/domain/work-orders/mocks/wo-table-mock-data.ts new file mode 100644 index 00000000..d1860745 --- /dev/null +++ b/src/domain/work-orders/mocks/wo-table-mock-data.ts @@ -0,0 +1,521 @@ +import type { + LocationOption, + VendorDropdownItem, + WorkOrderDetail, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; +import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; +import { + MOCK_WO_WIZARD_LOCATIONS, + MOCK_WO_WIZARD_SERVICES, + MOCK_WO_WIZARD_VENDORS, +} from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; +import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; +import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils"; +import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; + +export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({ + id, + name, +})); + +function sched(iso: string): Pick { + const { dayGroup, dayLabel } = deriveDayFields(iso); + return { scheduledOn: iso, dayGroup, dayLabel }; +} + +type RowSeed = { + id: string | number; + woNumber: string; + site: string; + location: string; + locationId: string | number; + type: WorkOrderTableRow["type"]; + dispatcherId: string; + dispatcherName: string; + scheduledOn?: string; + targetWeek?: string; + dueDate: string; + pm: string; + company: string; + tech: string; + techPhone: string; + apptStart: string; + apptEnd: string; + apptTime: string; + status: WorkOrderTableRow["status"]; + docStatus: WorkOrderTableRow["docStatus"]; + pocName: string; + pocPhone: string; + pocNotes: string; + rescheduleCount: number; + carriedOver: number; + originalWeek: string; + originalDate: string; + scheduleWeekOnly: boolean; + mediaCount: number; + title?: string; + priority?: string; +}; + +function buildRow(seed: RowSeed): WorkOrderTableRow { + const scheduledOn = seed.scheduledOn ?? ""; + const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields(""); + return enrichTableRow({ + title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`, + priority: seed.priority ?? "Sev 3", + ...seed, + ...dayFields, + scheduledOn, + targetWeek: seed.targetWeek ?? "", + isPastDue: false, + }); +} + +function buildMockRows(): WorkOrderTableRow[] { + const mon = currentWeekMonday(); + const tue = addDaysIso(mon, 1); + const wed = addDaysIso(mon, 2); + const thu = addDaysIso(mon, 3); + const fri = addDaysIso(mon, 4); + const yesterday = addDaysIso(todayIso(), -1); + + const sarah = MOCK_DISPATCHER_USERS[0]; + const alyssa = MOCK_DISPATCHER_USERS[1]; + const derek = MOCK_DISPATCHER_USERS[2]; + const morgan = MOCK_DISPATCHER_USERS[3]; + + return [ + buildRow({ + id: "mock-wo-1", + woNumber: "20260623001", + site: "CHI-1042", + location: "Chicago Loop", + locationId: "mock-loc-1", + type: "PM", + dispatcherId: String(sarah.id), + dispatcherName: sarah.name, + ...sched(mon), + dueDate: fri, + pm: "HVAC — Quarterly PM", + company: "Midwest HVAC Co.", + tech: "Mike Torres", + techPhone: "(312) 555-0198", + apptStart: "09:00", + apptEnd: "11:00", + apptTime: "09:00 – 11:00", + status: "Scheduled", + docStatus: "No", + pocName: "Maria Gonzalez", + pocPhone: "(312) 555-0142", + pocNotes: "Loading dock B", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-2", + woNumber: "20260623002", + site: "IND-2208", + location: "Indianapolis North", + locationId: "mock-loc-2", + type: "Emergency", + dispatcherId: String(alyssa.id), + dispatcherName: alyssa.name, + ...sched(tue), + dueDate: tue, + pm: "Plumbing — Leak repair", + company: "Great Lakes Plumbing", + tech: "Jen Walsh", + techPhone: "(317) 555-4412", + apptStart: "08:00", + apptEnd: "", + apptTime: "08:00", + status: "On Site", + docStatus: "No", + pocName: "Chris Bennett", + pocPhone: "(317) 555-2208", + pocNotes: "", + rescheduleCount: 1, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 3, + }), + buildRow({ + id: "mock-wo-3", + woNumber: "20260623003", + site: "MIL-3315", + location: "Milwaukee West", + locationId: "mock-loc-3", + type: "Reactive", + dispatcherId: String(derek.id), + dispatcherName: derek.name, + ...sched(wed), + dueDate: thu, + pm: "Electrical — Panel inspection", + company: "SparkLine Electric", + tech: "Pat Nguyen", + techPhone: "", + apptStart: "13:00", + apptEnd: "15:00", + apptTime: "13:00 – 15:00", + status: "In Progress", + docStatus: "Yes", + pocName: "Taylor Brooks", + pocPhone: "(414) 555-3315", + pocNotes: "Badge required at front desk", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-4", + woNumber: "20260623004", + site: "CHI-1042", + location: "Chicago Loop", + locationId: "mock-loc-1", + type: "Add-On", + dispatcherId: String(morgan.id), + dispatcherName: morgan.name, + ...sched(thu), + dueDate: fri, + pm: "Roof — Membrane patch", + company: "Midwest HVAC Co.", + tech: "Mike Torres", + techPhone: "(312) 555-0198", + apptStart: "10:30", + apptEnd: "12:00", + apptTime: "10:30 – 12:00", + status: "Pending", + docStatus: "NN", + pocName: "Maria Gonzalez", + pocPhone: "(312) 555-0142", + pocNotes: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-5", + woNumber: "20260623005", + site: "IND-2208", + location: "Indianapolis North", + locationId: "mock-loc-2", + type: "PM", + dispatcherId: "", + dispatcherName: "", + ...sched(fri), + dueDate: fri, + pm: "HVAC — Quarterly PM", + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-6", + woNumber: "20260623006", + site: "MIL-3315", + location: "Milwaukee West", + locationId: "mock-loc-3", + type: "Overdue", + dispatcherId: String(sarah.id), + dispatcherName: sarah.name, + ...sched(mon), + dueDate: yesterday, + pm: "Plumbing — Leak repair", + company: "Great Lakes Plumbing", + tech: "Jen Walsh", + techPhone: "(317) 555-4412", + apptStart: "07:00", + apptEnd: "09:00", + apptTime: "07:00 – 09:00", + status: "Scheduled", + docStatus: "No", + pocName: "Taylor Brooks", + pocPhone: "(414) 555-3315", + pocNotes: "", + rescheduleCount: 2, + carriedOver: 1, + originalWeek: "", + originalDate: yesterday, + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-7", + woNumber: "20260623007", + site: "CHI-1042", + location: "Chicago Loop", + locationId: "mock-loc-1", + type: "Reactive", + dispatcherId: String(alyssa.id), + dispatcherName: alyssa.name, + scheduledOn: "", + targetWeek: mon, + dueDate: wed, + pm: "Electrical — Panel inspection", + company: "SparkLine Electric", + tech: "Pat Nguyen", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "No", + pocName: "Maria Gonzalez", + pocPhone: "(312) 555-0142", + pocNotes: "Week-only target", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: mon, + originalDate: "", + scheduleWeekOnly: true, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-8", + woNumber: "20260623008", + site: "IND-2208", + location: "Indianapolis North", + locationId: "mock-loc-2", + type: "PM", + dispatcherId: String(derek.id), + dispatcherName: derek.name, + scheduledOn: "", + targetWeek: "", + dueDate: thu, + pm: "HVAC — Quarterly PM", + company: "Midwest HVAC Co.", + tech: "Mike Torres", + techPhone: "(312) 555-0198", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Pending", + docStatus: "No", + pocName: "Chris Bennett", + pocPhone: "(317) 555-2208", + pocNotes: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-9", + woNumber: "20260623009", + site: "MIL-3315", + location: "Milwaukee West", + locationId: "mock-loc-3", + type: "PM", + dispatcherId: String(morgan.id), + dispatcherName: morgan.name, + ...sched(wed), + dueDate: fri, + pm: "Roof — Membrane patch", + company: "SparkLine Electric", + tech: "Pat Nguyen", + techPhone: "", + apptStart: "14:00", + apptEnd: "16:00", + apptTime: "14:00 – 16:00", + status: "En Route", + docStatus: "No", + pocName: "Taylor Brooks", + pocPhone: "(414) 555-3315", + pocNotes: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 0, + }), + buildRow({ + id: "mock-wo-10", + woNumber: "20260623010", + site: "CHI-1042", + location: "Chicago Loop", + locationId: "mock-loc-1", + type: "Reactive", + dispatcherId: String(sarah.id), + dispatcherName: sarah.name, + ...sched(tue), + dueDate: wed, + pm: "Plumbing — Leak repair", + company: "Great Lakes Plumbing", + tech: "Jen Walsh", + techPhone: "(317) 555-4412", + apptStart: "11:00", + apptEnd: "12:30", + apptTime: "11:00 – 12:30", + status: "Completed", + docStatus: "Yes", + pocName: "Maria Gonzalez", + pocPhone: "(312) 555-0142", + pocNotes: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + scheduleWeekOnly: false, + mediaCount: 2, + }), + ]; +} + +let cachedRows: WorkOrderTableRow[] | null = null; + +export function getMockWorkOrderTableRows(): WorkOrderTableRow[] { + if (!cachedRows) { + cachedRows = buildMockRows(); + } + return cachedRows; +} + +function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean { + const q = search.trim().toLowerCase(); + if (!q) { + return true; + } + const haystack = [ + row.site, + row.location, + row.woNumber, + row.type, + row.dispatcherName, + row.pm, + row.company, + row.tech, + row.status, + row.title, + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); +} + +export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): { + items: WorkOrderTableRow[]; + totalCount: number; + page: number; + pageSize: number; +} { + let items = getMockWorkOrderTableRows(); + + if (params.search) { + items = items.filter((row) => rowMatchesSearch(row, params.search!)); + } + + if (params.type) { + items = items.filter((row) => row.type === params.type); + } + + if (params.dateFrom && params.dateTo) { + const weekMonday = params.dateFrom; + items = items.filter((row) => { + if (row.scheduledOn) { + return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!; + } + if (row.targetWeek) { + return isDateInWeek(row.targetWeek, weekMonday); + } + return true; + }); + } + + const page = params.page ?? 1; + const pageSize = params.pageSize ?? items.length; + + return { + items, + totalCount: items.length, + page, + pageSize, + }; +} + +export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined { + return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id)); +} + +export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail { + return { + id: row.id, + internalWONumber: row.woNumber, + workOrderNumber: row.woNumber, + title: row.title, + location: row.location, + locationId: String(row.locationId), + locationZip: "", + assignedTo: row.dispatcherName, + dueDate: row.dueDate, + createdDate: addDaysIso(todayIso(), -7), + createdDateFull: new Date().toISOString(), + description: [ + row.pocNotes && `POC notes: ${row.pocNotes}`, + row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`, + row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`, + row.company && `Vendor: ${row.company}`, + ] + .filter(Boolean) + .join("\n"), + priority: row.priority, + status: + row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress", + po: "", + tt: "", + problem: row.pm, + trade: "", + subTrade: "", + vendorNTE: "", + scheduledDate: row.scheduledOn, + completedDate: row.status === "Completed" ? todayIso() : "", + source: row.type, + comments: [], + auditLog: [], + dispatches: [], + }; +} + +export function getMockWorkOrderLocations(): LocationOption[] { + return MOCK_WO_WIZARD_LOCATIONS; +} + +export function getMockWorkOrderVendors(): VendorDropdownItem[] { + return MOCK_WO_WIZARD_VENDORS; +} + +export function getMockPmTypes(): string[] { + return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean); +} diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts new file mode 100644 index 00000000..075a250d --- /dev/null +++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts @@ -0,0 +1,158 @@ +import type { Location } from "@/domain/locations/types/location"; +import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option"; +import type { + LocationOption, + VendorDropdownItem, + WorkOrderDetail, + WorkOrderFormValues, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; + +export const MOCK_WO_WIZARD_USERS: WorkOrderUser[] = [ + { id: "mock-user-1", name: "Alex Rivera" }, + { id: "mock-user-2", name: "Jordan Kim" }, + { id: "mock-user-3", name: "Sam Patel" }, +]; + +export const MOCK_WO_WIZARD_LOCATIONS: LocationOption[] = [ + { id: "mock-loc-1", name: "CHI-1042 — Chicago Loop" }, + { id: "mock-loc-2", name: "IND-2208 — Indianapolis North" }, + { id: "mock-loc-3", name: "MIL-3315 — Milwaukee West" }, +]; + +const MOCK_LOCATION_DETAILS: Record = { + "mock-loc-1": { + id: "mock-loc-1", + name: "CHI-1042 — Chicago Loop", + address: "233 S Wacker Dr", + city: "Chicago", + state: "IL", + zipCode: "60606", + phone: "(312) 555-0142", + contact: "Maria Gonzalez", + contactEmail: "maria.gonzalez@example.com", + status: "Active", + }, + "mock-loc-2": { + id: "mock-loc-2", + name: "IND-2208 — Indianapolis North", + address: "8702 Keystone Crossing", + city: "Indianapolis", + state: "IN", + zipCode: "46240", + phone: "(317) 555-2208", + contact: "Chris Bennett", + contactEmail: "chris.bennett@example.com", + status: "Active", + }, + "mock-loc-3": { + id: "mock-loc-3", + name: "MIL-3315 — Milwaukee West", + address: "411 E Wisconsin Ave", + city: "Milwaukee", + state: "WI", + zipCode: "53202", + phone: "(414) 555-3315", + contact: "Taylor Brooks", + contactEmail: "taylor.brooks@example.com", + status: "Active", + }, +}; + +export const MOCK_WO_WIZARD_SERVICES: DropdownOption[] = [ + { + id: "mock-svc-1", + category: "Problem", + value: "HVAC — Quarterly PM", + parentValue: null, + sortOrder: 1, + isActive: true, + }, + { + id: "mock-svc-2", + category: "Problem", + value: "Plumbing — Leak repair", + parentValue: null, + sortOrder: 2, + isActive: true, + }, + { + id: "mock-svc-3", + category: "Problem", + value: "Electrical — Panel inspection", + parentValue: null, + sortOrder: 3, + isActive: true, + }, + { + id: "mock-svc-4", + category: "Problem", + value: "Roof — Membrane patch", + parentValue: null, + sortOrder: 4, + isActive: true, + }, +]; + +export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [ + { + id: "mock-vendor-1", + companyName: "Midwest HVAC Co.", + tradeSpecialties: "HVAC", + address: "1200 Industrial Pkwy, Chicago, IL 60632", + distanceMiles: 4.2, + }, + { + id: "mock-vendor-2", + companyName: "Great Lakes Plumbing", + tradeSpecialties: "Plumbing", + address: "455 Water St, Milwaukee, WI 53202", + distanceMiles: 8.7, + }, + { + id: "mock-vendor-3", + companyName: "SparkLine Electric", + tradeSpecialties: "Electrical", + address: "88 Volt Ave, Indianapolis, IN 46225", + distanceMiles: 12.1, + }, +]; + +export function getMockWoWizardLocationDetail(locationId: string): Location | undefined { + return MOCK_LOCATION_DETAILS[locationId]; +} + +export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrderDetail { + const id = `mock-wo-${Date.now()}`; + const now = new Date().toISOString(); + + return { + id, + internalWONumber: values.internalWONumber || id, + workOrderNumber: values.workOrderNumber || "MOCK-0001", + title: values.title || "Mock Work Order", + location: + MOCK_WO_WIZARD_LOCATIONS.find((l) => String(l.id) === values.locationId)?.name ?? "Mock Site", + locationId: values.locationId, + locationZip: getMockWoWizardLocationDetail(values.locationId)?.zipCode ?? "", + assignedTo: values.assignedTo, + dueDate: values.dueDate, + createdDate: values.createdDate, + createdDateFull: now, + description: values.description, + priority: values.priority, + status: values.status, + po: values.po, + tt: values.tt, + problem: values.problem, + trade: values.trade, + subTrade: values.subTrade, + vendorNTE: values.vendorNTE, + scheduledDate: values.scheduledDate, + completedDate: values.completedDate, + source: values.source, + comments: [], + auditLog: [], + dispatches: [], + }; +} diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts new file mode 100644 index 00000000..ad132df4 --- /dev/null +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -0,0 +1,240 @@ +import { + ASSIGNEE_FILTER_UNASSIGNED, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + EMPTY_ADVANCED_FILTERS, + type CompletionDocFilter, + type DateRangeKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + MONTH_NAMES, + addDaysIso, + mondayOf, + pad2, + todayIso, +} from "@/domain/work-orders/utils/wizard-date-utils"; + +export interface AdvancedFilterChip { + key: string; + group: string; + value?: string; + count?: number; + accent?: boolean; + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; +} + +function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters { + return { + ...filters, + sites: new Set(filters.sites), + types: new Set(filters.types), + dispatchers: new Set(filters.dispatchers), + statuses: new Set(filters.statuses), + pmTypes: new Set(filters.pmTypes), + vendorTechs: new Set(filters.vendorTechs), + docs: new Set(filters.docs), + }; +} + +export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { + let count = 0; + if (filters.dateRange !== "this-week") count += 1; + if (filters.sites.size) count += 1; + if (filters.types.size) count += 1; + if (filters.dispatchers.size) count += 1; + if (filters.statuses.size) count += 1; + if (filters.pmTypes.size) count += 1; + if (filters.vendorTechs.size) count += 1; + if (filters.docs.size) count += 1; + return count; +} + +function fmtChipDate(iso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ""; + const [, m, d] = iso.split("-").map(Number); + return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`; +} + +function monthStart(y: number, m: number): string { + return `${y}-${pad2(m + 1)}-01`; +} + +function monthEnd(y: number, m: number): string { + return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`; +} + +export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] { + const today = todayIso(); + const ty = Number(today.slice(0, 4)); + const tm = Number(today.slice(5, 7)) - 1; + const thisWeekMonday = mondayOf(today); + const lastWeekMonday = addDaysIso(thisWeekMonday, -7); + + switch (filters.dateRange) { + case "this-week": + return [thisWeekMonday, addDaysIso(thisWeekMonday, 4)]; + case "last-week": + return [lastWeekMonday, addDaysIso(lastWeekMonday, 4)]; + case "this-month": + return [monthStart(ty, tm), monthEnd(ty, tm)]; + case "last-3-months": { + const d = new Date(ty, tm - 2, 1); + return [monthStart(d.getFullYear(), d.getMonth()), monthEnd(ty, tm)]; + } + case "next-week": + return [today, addDaysIso(today, 7)]; + case "next-month": + return [today, addDaysIso(today, 30)]; + case "custom": + return [filters.customFrom || "0000-01-01", filters.customTo || "9999-12-31"]; + } +} + +function setChip( + chips: AdvancedFilterChip[], + key: string, + group: string, + set: Set, + nameOf: (value: string) => string, + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters, +) { + if (!set.size) return; + if (set.size <= 2) { + chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear }); + return; + } + chips.push({ key, group, count: set.size, clear }); +} + +export function buildAdvancedFilterChips( + filters: WorkOrderAdvancedFilters, + options?: { + siteNameOf?: (id: string) => string; + dispatcherNameOf?: (id: string) => string; + }, +): AdvancedFilterChip[] { + const chips: AdvancedFilterChip[] = []; + const siteNameOf = options?.siteNameOf ?? ((v) => v); + const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v); + + if (filters.dateRange !== "this-week") { + const value = + filters.dateRange === "custom" + ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` + : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); + chips.push({ + key: "date", + group: "Date", + value, + clear: (current) => ({ + ...current, + dateRange: "this-week", + customFrom: "", + customTo: "", + }), + }); + } + + setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({ + ...current, + sites: new Set(), + })); + + setChip( + chips, + "types", + "Type of WO", + filters.types as Set, + (v) => v, + (current) => ({ ...current, types: new Set() }), + ); + + const realDispatchers = new Set( + [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED), + ); + setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({ + ...current, + dispatchers: new Set( + [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED), + ), + })); + + if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) { + chips.push({ + key: "disp-unassigned", + group: "Assigned to", + value: "Unassigned", + accent: true, + clear: (current) => { + const next = new Set(current.dispatchers); + next.delete(ASSIGNEE_FILTER_UNASSIGNED); + return { ...current, dispatchers: next }; + }, + }); + } + + setChip( + chips, + "status", + "Status", + filters.statuses as Set, + (v) => v, + (current) => ({ ...current, statuses: new Set() }), + ); + + setChip( + chips, + "pm", + "PM type", + filters.pmTypes, + (v) => v, + (current) => ({ ...current, pmTypes: new Set() }), + ); + + setChip( + chips, + "vendor", + "Vendor / tech", + filters.vendorTechs, + (v) => v, + (current) => ({ ...current, vendorTechs: new Set() }), + ); + + setChip( + chips, + "doc", + "Comp doc", + filters.docs as Set, + (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v, + (current) => ({ ...current, docs: new Set() }), + ); + + return chips; +} + +export function clearAdvancedFilterGroup( + filters: WorkOrderAdvancedFilters, + chipKey: string, +): WorkOrderAdvancedFilters | null { + const chips = buildAdvancedFilterChips(filters); + const chip = chips.find((c) => c.key === chipKey); + if (!chip) return filters; + const next = chip.clear(cloneFilters(filters)); + return countAdvancedFilters(next) > 0 ? next : null; +} + +export function getInitialAdvancedFilters( + applied: WorkOrderAdvancedFilters | null, +): WorkOrderAdvancedFilters { + if (!applied) return cloneFilters(EMPTY_ADVANCED_FILTERS); + return cloneFilters(applied); +} + +export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters { + return cloneFilters(filters); +} + +export function isDefaultDateRange(dateRange: DateRangeKey): boolean { + return dateRange === "this-week"; +} diff --git a/src/domain/work-orders/utils/list-week-utils.ts b/src/domain/work-orders/utils/list-week-utils.ts new file mode 100644 index 00000000..221ad4bd --- /dev/null +++ b/src/domain/work-orders/utils/list-week-utils.ts @@ -0,0 +1,68 @@ +import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; + +export interface WorkWeek { + mondayIso: string; + label: string; + live: boolean; +} + +export function currentWeekMonday(): string { + return mondayOf(todayIso()); +} + +export function weekRange(mondayIso: string): [string, string] { + return [mondayIso, addDaysIso(mondayIso, 4)]; +} + +export function compactWeekLabel(mondayIso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) { + return ""; + } + const fridayIso = addDaysIso(mondayIso, 4); + const [, sm, sd] = mondayIso.split("-").map(Number); + const [, em, ed] = fridayIso.split("-").map(Number); + return `${sm}/${sd}–${em}/${ed}`; +} + +export function isLiveWeek(mondayIso: string): boolean { + return mondayIso === currentWeekMonday(); +} + +export function isFutureWeek(mondayIso: string): boolean { + return mondayIso > currentWeekMonday(); +} + +export function nextWeekMonday(): string { + return addDaysIso(currentWeekMonday(), 7); +} + +function toWorkWeek(mondayIso: string): WorkWeek { + return { + mondayIso, + label: compactWeekLabel(mondayIso), + live: isLiveWeek(mondayIso), + }; +} + +export function buildWeekWindow(anchorMonday: string, before = 4, after = 4): WorkWeek[] { + const weeks: WorkWeek[] = []; + const liveMonday = currentWeekMonday(); + const upcomingMonday = nextWeekMonday(); + + for (let i = -before; i <= after; i += 1) { + const mondayIso = addDaysIso(anchorMonday, i * 7); + if (isFutureWeek(mondayIso) && mondayIso !== upcomingMonday) { + continue; + } + weeks.push(toWorkWeek(mondayIso)); + } + + for (const requiredMonday of [liveMonday, upcomingMonday]) { + if (!weeks.some((week) => week.mondayIso === requiredMonday)) { + weeks.push(toWorkWeek(requiredMonday)); + } + } + + weeks.sort((a, b) => a.mondayIso.localeCompare(b.mondayIso)); + return weeks; +} diff --git a/src/domain/work-orders/utils/table-day-utils.ts b/src/domain/work-orders/utils/table-day-utils.ts new file mode 100644 index 00000000..49dc123b --- /dev/null +++ b/src/domain/work-orders/utils/table-day-utils.ts @@ -0,0 +1,46 @@ +import { addDaysIso, pad2 } from "@/domain/work-orders/utils/wizard-date-utils"; + +export const WEEKDAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] as const; + +export function deriveDayFields(rawDate: string): { + dayGroup: string; + dayLabel: string; +} { + const dayGroup = rawDate ? rawDate.replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2") : ""; + const dayLabel = rawDate + ? (() => { + const d = new Date(`${rawDate}T12:00:00`); + return `${WEEKDAY_LABELS[d.getDay()]} · ${dayGroup}`; + })() + : ""; + return { dayGroup, dayLabel }; +} + +export function buildWeekSkeleton(weekMonday: string): { + key: string; + label: string; + date: string; + items: never[]; +}[] { + const base = new Date(`${weekMonday}T12:00:00`); + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(base); + d.setDate(base.getDate() + i); + const key = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`; + const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + return { + key, + label: `${WEEKDAY_LABELS[d.getDay()]} · ${key}`, + date: iso, + items: [], + }; + }); +} + +export function isDateInWeek(iso: string, weekMonday: string): boolean { + if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) { + return false; + } + const friday = addDaysIso(weekMonday, 4); + return iso >= weekMonday && iso <= friday; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts new file mode 100644 index 00000000..afe15972 --- /dev/null +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -0,0 +1,166 @@ +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; +import type { + WorkOrderDayGroup, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; +import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; + +const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); + +export function computeIsPastDue(row: WorkOrderTableRow): boolean { + if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) { + return false; + } + const today = new Date().toISOString().slice(0, 10); + return row.dueDate < today; +} + +export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow { + return { + ...row, + isPastDue: computeIsPastDue(row), + }; +} + +export function matchesAdvancedFilters( + row: WorkOrderTableRow, + filters: WorkOrderAdvancedFilters, +): boolean { + const [from, to] = dateRangeFor(filters); + if (row.scheduledOn && (row.scheduledOn < from || row.scheduledOn > to)) { + return false; + } + if (filters.sites.size && !filters.sites.has(row.site)) { + return false; + } + if (filters.types.size && !filters.types.has(row.type as never)) { + return false; + } + if (filters.dispatchers.size) { + const unassignedMatch = + filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId; + const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId); + if (!unassignedMatch && !idMatch) { + return false; + } + } + if (filters.statuses.size && (!row.status || !filters.statuses.has(row.status as never))) { + return false; + } + if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) { + return false; + } + if (filters.vendorTechs.size && !filters.vendorTechs.has(row.tech)) { + return false; + } + if (filters.docs.size && !filters.docs.has(row.docStatus as never)) { + return false; + } + return true; +} + +function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) { + return true; + } + const haystack = [ + row.site, + row.location, + row.woNumber, + row.type, + row.dispatcherName, + row.scheduledOn, + row.dueDate, + row.pm, + row.company, + row.tech, + row.status, + row.title, + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); +} + +export function filterByBarAndSearch( + rows: WorkOrderTableRow[], + bar: WorkOrderBarFilters, + search: string, +): WorkOrderTableRow[] { + const [weekFrom, weekTo] = weekRange(bar.weekMonday); + + return rows.filter((row) => { + if (!rowMatchesSearch(row, search)) { + return false; + } + + if (bar.type !== "All" && row.type !== bar.type) { + return false; + } + + if (bar.dispatcherIds.size > 0) { + const unassignedMatch = + bar.dispatcherIds.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId; + const idMatch = !!row.dispatcherId && bar.dispatcherIds.has(String(row.dispatcherId)); + if (!unassignedMatch && !idMatch) { + return false; + } + } + + if (row.scheduledOn) { + if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) { + return false; + } + } else if (row.targetWeek) { + if (!isDateInWeek(row.targetWeek, bar.weekMonday)) { + return false; + } + } + + return true; + }); +} + +export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): { + scheduled: WorkOrderTableRow[]; + unscheduled: WorkOrderTableRow[]; +} { + const scheduled: WorkOrderTableRow[] = []; + const unscheduled: WorkOrderTableRow[] = []; + + for (const row of rows) { + if (!row.scheduledOn) { + unscheduled.push(row); + } else { + scheduled.push(row); + } + } + + return { scheduled, unscheduled }; +} + +export function buildDayGroups(scheduled: WorkOrderTableRow[]): WorkOrderDayGroup[] { + const groups: WorkOrderDayGroup[] = []; + + for (const row of scheduled) { + const existing = groups.find((group) => group.key === row.dayGroup); + if (existing) { + existing.items.push(row); + } else { + groups.push({ + key: row.dayGroup, + label: row.dayLabel, + date: row.scheduledOn, + items: [row], + }); + } + } + + groups.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + return groups; +} diff --git a/src/domain/work-orders/utils/wizard-date-utils.ts b/src/domain/work-orders/utils/wizard-date-utils.ts new file mode 100644 index 00000000..5f03d7c4 --- /dev/null +++ b/src/domain/work-orders/utils/wizard-date-utils.ts @@ -0,0 +1,148 @@ +export const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +export function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +export function todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function fmtDate(iso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) { + return ""; + } + const [y, m, d] = iso.split("-").map(Number); + return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}, ${y}`; +} + +export function addDaysIso(iso: string, days: number): string { + const d = new Date(`${iso}T12:00:00`); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function mondayOf(iso: string): string { + const d = new Date(`${iso}T12:00:00`); + const dow = d.getDay(); + d.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow)); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function weeksOfMonth(y: number, m: number): string[] { + const firstIso = `${y}-${pad2(m)}-01`; + const lastIso = `${y}-${pad2(m)}-${pad2(new Date(y, m, 0).getDate())}`; + const out: string[] = []; + for (let mon = mondayOf(firstIso); mon <= lastIso; mon = addDaysIso(mon, 7)) { + if (addDaysIso(mon, 4) >= firstIso) { + out.push(mon); + } + } + return out; +} + +export function weekRangeLabel(mondayIso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) { + return ""; + } + const fri = addDaysIso(mondayIso, 4); + const [, sm, sd] = mondayIso.split("-").map(Number); + const [, em, ed] = fri.split("-").map(Number); + return `${MONTH_NAMES[sm - 1].slice(0, 3)} ${sd} – ${MONTH_NAMES[em - 1].slice(0, 3)} ${ed}`; +} + +export function fmtWeekRange(startIso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) { + return fmtDate(startIso); + } + return weekRangeLabel(startIso); +} + +export function fmt12(t: string): string { + const [h, m] = t.split(":").map(Number); + const ampm = h < 12 ? "AM" : "PM"; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + return `${h12}:${String(m).padStart(2, "0")} ${ampm}`; +} + +export function calcDuration(s: string, e: string): string { + const [sh, sm] = s.split(":").map(Number); + const [eh, em] = e.split(":").map(Number); + const mins = eh * 60 + em - (sh * 60 + sm); + if (mins <= 0) { + return ""; + } + const h = Math.floor(mins / 60); + const m = mins % 60; + if (h && m) { + return `${h}h ${m}m`; + } + if (h) { + return `${h}h`; + } + return `${m}m`; +} + +export function formatApptRange(start: string, end: string): string { + if (!start) { + return "—"; + } + return end ? `${fmt12(start)} – ${fmt12(end)}` : fmt12(start); +} + +export const TIME_SLOTS: string[] = (() => { + const out: string[] = []; + for (let m = 5 * 60; m <= 22 * 60; m += 30) { + const h = Math.floor(m / 60); + const mm = m % 60; + out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`); + } + return out; +})(); + +export function parseApptTime(appt: string): { start: string; end: string } { + const m = appt.match(/(\d{1,2}):(\d{2})\s*[–-]\s*(\d{1,2}):(\d{2})/); + if (m) { + return { + start: `${m[1].padStart(2, "0")}:${m[2]}`, + end: `${m[3].padStart(2, "0")}:${m[4]}`, + }; + } + const single = appt.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (single) { + let h = Number(single[1]) % 12; + if (/pm/i.test(single[3])) { + h += 12; + } + return { start: `${pad2(h)}:${single[2]}`, end: "" }; + } + return { start: "", end: "" }; +} + +export function formatApptTimeValue(start: string, end: string): string { + if (!start) { + return ""; + } + return end ? `${start} – ${end}` : start; +} + +export function fmtWeekOf(mondayIso: string): string { + if (!mondayIso) { + return ""; + } + return `Week of ${weekRangeLabel(mondayIso)}`; +} From ff32c0da06e4666cd25bceb65d14a29a5ba91cc8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 14:28:09 -0300 Subject: [PATCH 04/24] test(work-orders): fix advanced filter date fixture and unused import --- src/test/domain/work-orders/utils/list-week-utils.test.ts | 2 +- src/test/domain/work-orders/utils/table-filter-utils.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/domain/work-orders/utils/list-week-utils.test.ts b/src/test/domain/work-orders/utils/list-week-utils.test.ts index 3c221b9b..46cf2cf6 100644 --- a/src/test/domain/work-orders/utils/list-week-utils.test.ts +++ b/src/test/domain/work-orders/utils/list-week-utils.test.ts @@ -7,7 +7,7 @@ import { nextWeekMonday, weekRange, } from "@/domain/work-orders/utils/list-week-utils"; -import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; +import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; describe("list-week-utils", () => { it("currentWeekMonday returns a Monday ISO date", () => { diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 8e066e18..1136695a 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -95,6 +95,9 @@ describe("matchesAdvancedFilters", () => { const row = makeRow({ site: "SITE01", type: "PM" }); const filters = { ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "0000-01-01", + customTo: "9999-12-31", sites: new Set(["SITE01"]), types: new Set(["PM" as const]), }; From bb8961cc714d539663601b49f1c020c260fdf6b5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 14:31:58 -0300 Subject: [PATCH 05/24] docs(work-orders): document overdue=true instead of types=99 --- docs/work-orders/board-search-api.md | 67 +++++++++++++++++----------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index 14323dd0..fde9438b 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -36,26 +36,27 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`. | — | `weekEnd` | `weekStart + 4` (sexta) | | `dispatcherIds` | `dispatchers` | `__unassigned` → `__unassigned__` | | "My WOs" | `dispatchers={userId}` | Equivalente a `myWorkOrders=true` | -| `typeFilter` | `types` | PM=2, Emergency=3, Reactive=6, Add-On=7, **Overdue=99** | +| `typeFilter` | `types` / `overdue` | Tipos reais em `types`; past-due via `overdue=true` (OR) | ## Advanced → `GET /board/search` -| FE state | API param | Regra | -| ---------------- | --------------------- | ------------------------------------------ | -| `page` | `page` | **0-based** (primeira página = `0`) | -| `pageSize` | `pageSize` | Default `100`, max `100` | -| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` | -| `sortDir` | `sortDir` | `asc` \| `desc` | -| `search` | `search` | `>= 2` chars | -| `dateRange` | `datePreset` | PascalCase (ver abaixo) | -| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` | -| `sites` (IDs UI) | `sites` | **Site codes**, não location ID | -| `types` | `types` | Integers; Overdue → `99` | -| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` | -| `statuses` | `statuses` | Integers 1–10 | -| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) | -| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) | -| `docs` | `docStatuses` | No=2, Yes=1, NN=3 | +| FE state | API param | Regra | +| ---------------- | --------------------- | ------------------------------------------- | +| `page` | `page` | **0-based** (primeira página = `0`) | +| `pageSize` | `pageSize` | Default `100`, max `100` | +| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` | +| `sortDir` | `sortDir` | `asc` \| `desc` | +| `search` | `search` | `>= 2` chars | +| `dateRange` | `datePreset` | PascalCase (ver abaixo) | +| `customFrom/To` | `dateFrom` / `dateTo` | Obrigatórios se `datePreset=Custom` | +| `sites` (IDs UI) | `sites` | **Site codes**, não location ID | +| `types` | `types` | Integers de `WorkOrderType` reais | +| overdue (UI) | `overdue` | `true` = past-due (`isPastDue`); ver abaixo | +| `dispatchers` | `dispatchers` | `__unassigned` → `__unassigned__` | +| `statuses` | `statuses` | Integers 1–10 | +| `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) | +| `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) | +| `docs` | `docStatuses` | No=2, Yes=1, NN=3 | ### `datePreset` @@ -69,15 +70,31 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`. | `next-month` | `NextMonth` | | `custom` | `Custom` | -### `types` — WorkOrderType / overdue +### `types` — WorkOrderType -| Label FE | API | -| --------- | ------------------------- | -| PM | `2` | -| Emergency | `3` | -| Reactive | `6` | -| Add-On | `7` | -| Overdue | `99` (filtra `isPastDue`) | +Somente tipos reais de work order. **Não** use sentinel `99` para overdue. + +| Label FE | API | +| --------- | -------------------------------------------- | +| PM | `2` | +| Emergency | `3` | +| Reactive | `6` | +| Add-On | `7` | +| Other | tipo real do enum (não é filtro de past-due) | + +### `overdue` — past-due + +| FE / UI | API param | Regra | +| ------- | -------------- | ----------------------------------------- | +| Overdue | `overdue=true` | Filtra work orders past-due (`isPastDue`) | + +`types` e `overdue` combinam com **OR**: uma WO entra no resultado se corresponder a algum `types` **ou** estiver overdue (quando `overdue=true`). + +Exemplos: + +- Só PM e Emergency: `types=2&types=3` +- Só past-due: `overdue=true` +- PM **ou** past-due: `types=2&overdue=true` ### `statuses` — LifecycleStatus From e78b1ad6ba899707103181d1211858e552f0b3cf Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:03:50 -0300 Subject: [PATCH 06/24] feat(work-orders): add weekly board page shell and list chrome --- src/app/(protected)/workorders/[id].tsx | 496 +----------------- .../list/filter-bar/dispatcher-filter.tsx | 168 ++++++ .../_components/list/filter-bar/week-nav.tsx | 131 +++++ .../list/filter-bar/wo-type-segment.tsx | 25 + .../filter-bar/work-orders-filter-bar.tsx | 48 ++ .../work-orders-advanced-filters-banner.tsx | 53 ++ .../list/work-orders-list-header.tsx | 63 +++ .../_components/list/work-orders-table.tsx | 117 +++++ .../_components/media/media-upload-modal.tsx | 65 +++ .../_components/media/media-uploader.tsx | 143 +++++ .../_hooks/use-work-orders-list-filters.ts | 191 +++++++ src/app/(protected)/workorders/index.tsx | 480 ++++++----------- 12 files changed, 1193 insertions(+), 787 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-table.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-upload-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index a282040c..9deeb752 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,36 +1,20 @@ -import { useMemo, useState } from "react"; -import { Link as RouterLink, useNavigate, useParams } from "react-router"; +import { Link as RouterLink, useNavigate } from "react-router"; 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 { useState } from "react"; +import { useParams } from "react-router"; 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 { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections"; +import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu"; import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useAddWorkOrderComment, @@ -38,167 +22,20 @@ import { 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 ( - - No comments yet. - - ); - } - - return ( - - {sorted.map((comment) => ( - - - - {comment.userName || comment.commenter || "System"} - - - {comment.createdDate ? formatDateTime(comment.createdDate) : ""} - - - {comment.commenttext} - - ))} - - ); -} - -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 ( - - No changes recorded. - - ); - } - - return ( - - {sorted.map((entry) => ( - - - - {entry.userName || "System"} - - - {entry.createdAt ? formatDateTime(entry.createdAt) : ""} - - - - {entry.fieldName} {entry.oldValue} → {entry.newValue} - - - ))} - - ); -} export default function WorkOrderViewPage() { const { id } = useParams(); const navigate = useNavigate(); const workOrderId = id ?? ""; - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId); const { data: users = [] } = useWorkOrderUsers(); const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); const commentMutation = useAddWorkOrderComment(workOrderId); - const [activeTab, setActiveTab] = useState("customer"); - const [commentText, setCommentText] = useState(""); const [dispatchOpen, setDispatchOpen] = useState(false); const [dispatchDetailId, setDispatchDetailId] = useState(null); - const [statusAnchor, setStatusAnchor] = useState(null); - const [assignAnchor, setAssignAnchor] = useState(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 === "customer" ? "customer" : "internal", - }, - { onSuccess: () => setCommentText("") }, - ); - }; if (isLoading) { return ( @@ -224,14 +61,9 @@ export default function WorkOrderViewPage() { Work Orders - - {wo.internalWONumber} - + {wo.internalWONumber} + {wo.title} - setStatusAnchor(event.currentTarget)} - className="w-fit cursor-pointer" + statusMutation.mutate(status)} /> - setStatusAnchor(null)} - > - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - { - setStatusAnchor(null); - if (status !== wo.status) { - statusMutation.mutate(status); - } - }} - > - {status} - - ))} - - + @@ -290,283 +94,27 @@ export default function WorkOrderViewPage() { - - - Details - - - - - - - - - Assigned To - - - setAssignAnchor(null)} - > - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: "", userName: "" }); - }} - > - Unassigned - - {users.map((user) => ( - { - setAssignAnchor(null); - assignmentMutation.mutate({ userId: user.id, userName: user.name }); - }} - > - {user.name} - - ))} - - - - - - - - }> - - Classification - - - - - - - - - - - - }> - - Schedule - - - - - - - - - - - - - }> - - Financial - - - - - - - - - - - - }> - - Dispatches ({wo.dispatches.length}) - - - - {wo.dispatches.length ? ( - - - - - Dispatch # - Vendor - NTE - Status - Date - Wait - - - - {wo.dispatches.map((dispatch) => { - const waitColor = waitTimeColor(dispatch.dispatchedAt); - return ( - setDispatchDetailId(dispatch.id)} - > - {dispatch.dispatchNumber || dispatch.poNumber} - {dispatch.vendorName} - - {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} - - {dispatch.status} - - {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} - - - {dispatch.status === "Sent" ? ( - - {timeSince(dispatch.dispatchedAt)} - - ) : ( - "—" - )} - - - ); - })} - -
-
- ) : ( - - No dispatches yet. - - )} -
-
- - }> - - Description - - - - {wo.description || "No description provided."} - - - - - Activity - - setActiveTab(value)} - className="mb-3" - > - - - - - {activeTab === "customer" && } - {activeTab === "internal" && } - {activeTab === "audit" && } + assignmentMutation.mutate({ userId, userName })} + onDispatchClick={setDispatchDetailId} + onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })} + isCommentPending={commentMutation.isPending} + /> - {activeTab === "internal" && ( - - setCommentText(event.target.value)} - placeholder="Type a comment..." - disabled={commentMutation.isPending} - /> - - - )} - {dispatchOpen && ( - setDispatchOpen(false)} - onDispatched={() => void refetch()} - /> + setDispatchOpen(false)} /> )} + {dispatchDetailId && ( setDispatchDetailId(null)} - onUpdated={() => void refetch()} /> )} ); } - -function Field({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - {value} - - ); -} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx new file mode 100644 index 00000000..9e85a9ac --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import { Popover } from "@mui/material"; +import { ChevronDown, UserX, Users } from "lucide-react"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + MOCK_DISPATCHER_USERS, + getDispatcherAvatarColor, +} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; + +type DispatcherFilterProps = { + selectedIds: Set; + onChange: (ids: Set) => void; + users: WorkOrderUser[]; + currentUserId?: string | number; +}; + +function userInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); +} + +function DispatcherAvatar({ name, color }: { name: string; color: string }) { + return ( + + {userInitials(name)} + + ); +} + +export function DispatcherFilter({ + selectedIds, + onChange, + users, + currentUserId, +}: DispatcherFilterProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + + const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + const allSelected = selectedIds.size === 0; + const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId); + const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED); + + const label = useMemo(() => { + if (allSelected) return "All dispatchers"; + if (meOnly) return "My WOs"; + if (unassignedOnly) return "Unassigned"; + return `${selectedIds.size} selected`; + }, [allSelected, meOnly, unassignedOnly, selectedIds.size]); + + const dispatchers = useMemo(() => { + const byId = new Map(); + for (const user of [...MOCK_DISPATCHER_USERS, ...users]) { + byId.set(String(user.id), user); + } + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [users]); + + const toggle = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onChange(next); + }; + + return ( + <> + + + {label} + + + + + onChange(new Set())} + /> + + {meId && ( + onChange(new Set([meId]))} + /> + )} + + } + className="mb-1 pb-2" + style={{ borderBottom: "1px solid var(--color-border)" }} + onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)} + /> + + {dispatchers.map((user) => { + const id = String(user.id); + const isMe = meId === id; + return ( + } + right={ + isMe ? me : undefined + } + onClick={() => toggle(id)} + /> + ); + })} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx new file mode 100644 index 00000000..a99e3a02 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { NavArrowButton } from "@/components/ui/nav-arrow-button"; + +import { NavPillButton } from "@/components/ui/nav-pill-button"; + +import { + buildWeekWindow, + isFutureWeek, + isLiveWeek, +} from "@/domain/work-orders/utils/list-week-utils"; + +type WeekNavProps = { + weekMonday: string; + + onChange: (mondayIso: string) => void; +}; + +const VISIBLE_COUNT = 3; + +const WINDOW_BEFORE = 12; + +const WINDOW_AFTER = 1; + +function WeekLiveBadge() { + return ( + + Live + + ); +} + +export function WeekNav({ weekMonday, onChange }: WeekNavProps) { + const weeks = useMemo( + () => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER), + + [weekMonday], + ); + + const activeIndex = useMemo( + () => weeks.findIndex((week) => week.mondayIso === weekMonday), + + [weeks, weekMonday], + ); + + const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0)); + + useEffect(() => { + if (activeIndex < 0) return; + + const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT); + + setOffset((current) => { + if (activeIndex < current) return Math.min(activeIndex, maxOffset); + + if (activeIndex > current + VISIBLE_COUNT - 1) { + return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset); + } + + return current; + }); + }, [activeIndex, weeks.length]); + + const visible = weeks.slice(offset, offset + VISIBLE_COUNT); + + const canGoBack = offset > 0; + + const canGoForward = offset + VISIBLE_COUNT < weeks.length; + + return ( +
+ setOffset((current) => current - 1)} + aria-label="Previous weeks" + > + + + + {visible.map((week) => { + const isActive = weekMonday === week.mondayIso; + + const isDisabled = isFutureWeek(week.mondayIso); + + const showLiveBadge = isLiveWeek(week.mondayIso); + + return ( + onChange(week.mondayIso)} + > + {week.label} + + {showLiveBadge && } + + ); + })} + + setOffset((current) => current + 1)} + aria-label="Next weeks" + > + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx new file mode 100644 index 00000000..3810f8ec --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx @@ -0,0 +1,25 @@ +import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control"; + +import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; + +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; + +type WoTypeSegmentProps = { + value: WorkOrderTypeFilter; + + onChange: (value: WorkOrderTypeFilter) => void; +}; + +const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES]; + +export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) { + return ( + + {OPTIONS.map((option) => ( + onChange(option)}> + {option} + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx new file mode 100644 index 00000000..4801105c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -0,0 +1,48 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; +import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; +import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav"; +import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment"; + +type WorkOrdersFilterBarProps = { + weekMonday: string; + onWeekChange: (mondayIso: string) => void; + dispatcherIds: Set; + onDispatcherChange: (ids: Set) => void; + typeFilter: WorkOrderTypeFilter; + onTypeChange: (value: WorkOrderTypeFilter) => void; + users: WorkOrderUser[]; + currentUserId?: string | number; + filteredCount: number; + scopeTotalCount: number; +}; + +export function WorkOrdersFilterBar({ + weekMonday, + onWeekChange, + dispatcherIds, + onDispatcherChange, + typeFilter, + onTypeChange, + users, + currentUserId, + filteredCount, + scopeTotalCount, +}: WorkOrdersFilterBarProps) { + const resultLabel = `${filteredCount} of ${scopeTotalCount}`; + + return ( +
+ + + +
+ {resultLabel} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx b/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx new file mode 100644 index 00000000..7b729bd1 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner.tsx @@ -0,0 +1,53 @@ +import { AppliedFilterChip } from "@/components/ui/applied-filter-chip"; +import { AppliedFiltersBar } from "@/components/ui/applied-filters-bar"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + buildAdvancedFilterChips, + countAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +type WorkOrdersAdvancedFiltersBannerProps = { + applied: WorkOrderAdvancedFilters; + totalCount: number; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + onClearChip: (chipKey: string) => void; + onClearAll: () => void; +}; + +export function WorkOrdersAdvancedFiltersBanner({ + applied, + totalCount, + sites, + users, + onClearChip, + onClearAll, +}: WorkOrdersAdvancedFiltersBannerProps) { + if (countAdvancedFilters(applied) === 0) { + return null; + } + + const siteNameOf = (id: string) => sites.find((site) => String(site.id) === id)?.name ?? id; + const dispatcherNameOf = (id: string) => users.find((user) => String(user.id) === id)?.name ?? id; + + const chips = buildAdvancedFilterChips(applied, { siteNameOf, dispatcherNameOf }); + + return ( + + {chips.map((chip) => ( + onClearChip(chip.key)} + /> + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx new file mode 100644 index 00000000..71dd3c7e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx @@ -0,0 +1,63 @@ +import { CircularProgress } from "@mui/material"; + +import { Plus, SlidersHorizontal } from "lucide-react"; + +import { SearchInput } from "@/components/ui/search-input"; + +import { ToolbarButton } from "@/components/ui/toolbar-button"; + +type WorkOrdersListHeaderProps = { + search: string; + + onSearchChange: (value: string) => void; + + onOpenAdvancedFilters: () => void; + + onNewWorkOrder: () => void; + + activeFilterCount: number; + + isFetching?: boolean; +}; + +export function WorkOrdersListHeader({ + search, + + onSearchChange, + + onOpenAdvancedFilters, + + onNewWorkOrder, + + activeFilterCount, + + isFetching = false, +}: WorkOrdersListHeaderProps) { + return ( + <> + + + } + onClick={onOpenAdvancedFilters} + aria-label="Advanced Filters" + title="Advanced Filters" + badge={activeFilterCount} + > + Filters + + + } onClick={onNewWorkOrder}> + New WO + + + {isFetching && } + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-table.tsx b/src/app/(protected)/workorders/_components/list/work-orders-table.tsx new file mode 100644 index 00000000..5579f5ca --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-table.tsx @@ -0,0 +1,117 @@ +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Button, IconButton, Tooltip } from "@mui/material"; +import { ResourceListTable, type ResourceListColumn } from "@/components/ui/resource-list-table"; +import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; +import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order"; +import type { SortDirection } from "@/domain/work-orders/types/work-order-list"; +import { formatDateTime } from "@/lib/time-utils"; + +function displayDateTime(value: string): string { + return formatDateTime(value) || "—"; +} + +type WorkOrdersTableProps = { + rows: WorkOrderListItem[]; + isLoading: boolean; + sort: { column: string; direction: SortDirection }; + onSort: (column: string) => void; + onView: (id: string | number) => void; + onEdit: (id: string | number) => void; +}; + +export function WorkOrdersTable({ + rows, + isLoading, + sort, + onSort, + onView, + onEdit, +}: WorkOrdersTableProps) { + const columns: ResourceListColumn[] = [ + { + id: "number", + header: "WO #", + sortable: true, + render: (row) => ( + + ), + }, + { + id: "title", + header: "Title", + sortable: true, + render: (row) => row.title, + }, + { + id: "location", + header: "Location", + render: (row) => row.location, + }, + { + id: "priority", + header: "Priority", + render: (row) => row.priority, + }, + { + id: "status", + header: "Status", + render: (row) => , + }, + { + id: "createdDate", + header: "Created", + cellClassName: "whitespace-nowrap", + render: (row) => displayDateTime(row.createdDate), + }, + { + id: "assignedTo", + header: "Assigned To", + render: (row) => row.assignedTo || "Unassigned", + }, + { + id: "lastUpdated", + header: "Last Updated", + cellClassName: "whitespace-nowrap", + render: (row) => displayDateTime(row.lastUpdated), + }, + { + id: "actions", + header: "Actions", + align: "right", + render: (row) => ( + <> + + onView(row.id)}> + + + + + onEdit(row.id)}> + + + + + ), + }, + ]; + + return ( + String(row.id)} + isLoading={isLoading} + emptyMessage="No work orders found" + sort={sort} + onSort={onSort} + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx new file mode 100644 index 00000000..921e7d21 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -0,0 +1,65 @@ +import { type MouseEvent } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Image as ImageIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { MediaUploader } from "./media-uploader"; + +type MediaUploadModalProps = { + row: WorkOrderTableRow; + open: boolean; + onOpenChange: (open: boolean) => void; + uploads: MediaFile[]; + onChange: (next: MediaFile[]) => void; +}; + +export function MediaUploadModal({ + row, + open, + onOpenChange, + uploads, + onChange, +}: MediaUploadModalProps) { + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + style: { borderRadius: 12, maxWidth: 520, width: "100%" }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> +
+ + + Photos & Videos + + #{row.woNumber} +
+

Upload photos and videos for this work order.

+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx new file mode 100644 index 00000000..bd7fcc9a --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -0,0 +1,143 @@ +import { useRef } from "react"; +import { + File as FileIcon, + FileText, + Film, + Image as ImageIcon, + Paperclip, + Play, + Upload, + X, +} from "lucide-react"; +import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; + +const FILETYPE_ICON: Record = { + image: ImageIcon, + video: Film, + pdf: FileText, + doc: FileIcon, + other: Paperclip, +}; + +type MediaUploaderProps = { + uploads: MediaFile[]; + onChange: (next: MediaFile[]) => void; +}; + +export function MediaUploader({ uploads, onChange }: MediaUploaderProps) { + const inputRef = useRef(null); + + const addFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + onChange([...uploads, ...Array.from(files).map(makeMediaFile)]); + }; + + const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id)); + + return ( +
+ + addFiles(e.target.files)} + /> + {uploads.length > 0 && ( +
+ {uploads.map((u) => { + const TypeIcon = FILETYPE_ICON[u.fileType]; + return ( +
+
+ {u.fileType === "image" ? ( + {u.name} + ) : u.fileType === "video" ? ( + <> +
+
+
+ {u.name} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts new file mode 100644 index 00000000..a02cce3c --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts @@ -0,0 +1,191 @@ +import { useMemo, useState } from "react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled"; +import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; +import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { + WorkOrderBarFilters, + WorkOrderTypeFilter, +} from "@/domain/work-orders/types/work-order-list-filters"; +import { + clearAdvancedFilterGroup, + cloneAdvancedFilters, + countAdvancedFilters, + getInitialAdvancedFilters, +} from "@/domain/work-orders/utils/advanced-filters-utils"; +import { + compactWeekLabel, + currentWeekMonday, + weekRange, +} from "@/domain/work-orders/utils/list-week-utils"; + +function defaultDispatcherIds(currentUserId?: string | number): Set { + if (isWoTableMockEnabled()) { + return new Set(); + } + if (currentUserId != null && currentUserId !== "") { + return new Set([String(currentUserId)]); + } + return new Set(); +} + +/** Phase 2: map bar filters to API list params. */ +export function toListParamsFromBar( + bar: WorkOrderBarFilters, +): Pick { + const [dateFrom, dateTo] = weekRange(bar.weekMonday); + return { + dateFrom, + dateTo, + type: bar.type === "All" ? undefined : bar.type, + assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined, + }; +} + +export function useWorkOrdersListFilters(currentUserId?: string | number) { + const [search, setSearch] = useState(""); + const [advOpen, setAdvOpen] = useState(false); + const [advApplied, setAdvApplied] = useState(null); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [sortBy, setSortBy] = useState(""); + const [sortDir, setSortDir] = useState<"asc" | "desc" | "">(""); + const [weekMonday, setWeekMondayState] = useState(currentWeekMonday); + const [dispatcherIds, setDispatcherIdsState] = useState>(() => + defaultDispatcherIds(currentUserId), + ); + const [typeFilter, setTypeFilterState] = useState("All"); + + const debouncedSearch = useDebounce(search, 300); + + const barFilters = useMemo( + () => ({ + weekMonday, + dispatcherIds, + type: typeFilter, + }), + [weekMonday, dispatcherIds, typeFilter], + ); + + const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]); + + const scopeListParams = useMemo( + () => ({ + page: 1, + pageSize: 1, + ...toListParamsFromBar(barFilters), + }), + [barFilters], + ); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: 1, + pageSize: WORK_ORDERS_TABLE_PAGE_SIZE, + sortBy, + sortDir, + ...toListParamsFromBar(barFilters), + }), + [debouncedSearch, sortBy, sortDir, barFilters], + ); + + const activeFilterCount = useMemo( + () => (advApplied ? countAdvancedFilters(advApplied) : 0), + [advApplied], + ); + + const setWeekMonday = (value: string) => { + setWeekMondayState(value); + setPage(0); + }; + + const setDispatcherIds = (value: Set) => { + setDispatcherIdsState(new Set(value)); + setPage(0); + }; + + const setTypeFilter = (value: WorkOrderTypeFilter) => { + setTypeFilterState(value); + setPage(0); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + setPage(0); + }; + + const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => { + setAdvApplied(cloneAdvancedFilters(filters)); + setPage(0); + }; + + const clearAdvancedFilters = () => { + setAdvApplied(null); + setPage(0); + }; + + const clearAdvancedFilterChip = (chipKey: string) => { + if (!advApplied) return; + const next = clearAdvancedFilterGroup(advApplied, chipKey); + setAdvApplied(next); + setPage(0); + }; + + const handleSort = (column: string) => { + if (sortBy === column) { + setSortDir((current) => { + const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc"; + if (next === "") { + setSortBy(""); + } + return next; + }); + return; + } + + setSortBy(column); + setSortDir("asc"); + }; + + const sortIndicator = (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : " ↑"; + }; + + return { + search, + advOpen, + advApplied, + advInitial: getInitialAdvancedFilters(advApplied), + page, + pageSize, + sortBy, + sortDir, + weekMonday, + dispatcherIds, + typeFilter, + barFilters, + weekLabel, + listParams, + scopeListParams, + activeFilterCount, + handleSearchChange, + applyAdvancedFilters, + clearAdvancedFilters, + clearAdvancedFilterChip, + handleSort, + sortIndicator, + setAdvOpen, + setPage, + setPageSize, + setWeekMonday, + setDispatcherIds, + setTypeFilter, + setSortBy, + setSortDir, + }; +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 5fabb151..98de7ec7 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,337 +1,191 @@ import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; -import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; +import { Box } from "@mui/material"; +import { PageHeader } from "@/components/ui/page-header"; +import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; +import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; +import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; +import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; +import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; +import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; 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"; + useWorkOrderLocations, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled"; +import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data"; +import type { CompDocData } from "@/domain/work-orders/types/completion-doc"; +import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -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(); -} +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { useAuthContext } from "@/providers/auth-context"; 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 { user } = useAuthContext(); + const [wizardOpen, setWizardOpen] = useState(false); + const [slideOverRow, setSlideOverRow] = useState(null); + const [slideOverEdit, setSlideOverEdit] = useState(false); + const [docRow, setDocRow] = useState(null); + const [mediaRow, setMediaRow] = useState(null); + const [completionDocs, setCompletionDocs] = useState>({}); + const [mediaFiles, setMediaFiles] = useState>({}); + const filters = useWorkOrdersListFilters(user?.id); + const tableData = useWorkOrdersTableData(filters); + const { patchField } = useWorkOrderTableMutations(tableData.patchRow); - const debouncedSearch = useDebounce(search, 300); + const { data: users = [] } = useWorkOrderUsers(); + const { data: locations = [] } = useWorkOrderLocations(); + const { data: vendors = [] } = useWorkOrderVendors(); + const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const listParams = useMemo( - () => ({ - search: debouncedSearch, - page: page + 1, - pageSize, - sortBy, - sortDir, - status: statusFilter, - assignee: assigneeFilter, - }), - [debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter], + const pmTypes = useMemo( + () => + isWoTableMockEnabled() + ? getMockPmTypes() + : problemOptions.map((option) => option.value).filter(Boolean), + [problemOptions], ); - const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams); - const { data: users = [] } = useWorkOrderUsers(); + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); - const rows = data?.items ?? []; - const totalCount = data?.totalCount ?? 0; + const totalCount = tableData.totalCount; + const scopeTotalCount = tableData.scopeTotalCount; - const handleSort = (column: string) => { - if (sortBy === column) { - setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc")); - if (sortDir === "desc") { - setSortBy(""); - } - return; - } + const activeDocRow = useMemo(() => { + if (!docRow) return null; + return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; + }, [docRow, tableData.rows]); - setSortBy(column); - setSortDir("asc"); - }; + const activeMediaRow = useMemo(() => { + if (!mediaRow) return null; + return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; + }, [mediaRow, tableData.rows]); - const sortIndicator = (column: string) => { - if (sortBy !== column) { - return ""; - } - return sortDir === "desc" ? " ↓" : " ↑"; - }; - - const handleStatusFilter = (status: string) => { - setStatusFilter((current) => (current === status ? "" : status)); - setPage(0); + const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { + setSlideOverRow(row); + setSlideOverEdit(edit); }; return ( - - - - - Work Orders - - - {totalCount} total - - {isFetching && !isLoading && } - - - - { - setSearch(event.target.value); - setPage(0); - }} - placeholder="Search..." - className="min-w-[220px]" + + filters.setAdvOpen(true)} + onNewWorkOrder={() => setWizardOpen(true)} + activeFilterCount={filters.activeFilterCount} + isFetching={tableData.isFetching && !tableData.isLoading} /> - - - - + + + + {filters.advApplied && ( + + )} + + filters.handleSearchChange("")} + onOpenAdvancedFilters={() => filters.setAdvOpen(true)} + /> + + + + + + { + setSlideOverRow(null); + setSlideOverEdit(false); + tableData.setSelectedId(null); }} - > - { - setStatusFilter(""); - setPage(0); + /> + + {activeMediaRow && ( + { + if (!open) setMediaRow(null); + }} + uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} + onChange={(files) => { + setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files })); + patchField(activeMediaRow.id, { mediaCount: files.length }); }} /> - {STATUS_OPTIONS.map((status) => ( - handleStatusFilter(status)} - /> - ))} - - - Assignee - - - - {error && ( - - {error instanceof Error ? error.message : "Failed to load work orders"} - )} - - - - - handleSort("number")} - > - WO #{sortIndicator("number")} - - handleSort("title")} - > - Title{sortIndicator("title")} - - Location - Priority - Status - Created - Assigned To - Last Updated - - Actions - - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No work orders found - - - - ) : ( - rows.map((row: WorkOrderTableRow) => ( - - - - - {row.title} - {row.location} - {row.priority} - - - - - {formatDateTime(row.scheduledOn)} - - {row.dispatcherName || "Unassigned"} - {formatDateTime(row.dueDate)} - - - navigate(`/workorders/${row.id}`)} - > - - - - - navigate(`/workorders/edit/${row.id}`)} - > - - - - - - )) - )} - -
-
- setPage(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(event) => { - setPageSize(Number(event.target.value)); - setPage(0); - }} - rowsPerPageOptions={[12, 24, 48]} - /> + + {activeDocRow && ( + { + if (!open) setDocRow(null); + }} + data={completionDocs[String(activeDocRow.id)]} + onSave={(data, status) => { + setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); + patchField(activeDocRow.id, { docStatus: status }); + }} + onStatusChange={(status) => patchField(activeDocRow.id, { status })} + onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })} + /> + )}
); } From 11f2c8b72c285f2c896cc030cecd132167565a58 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 10:18:09 -0300 Subject: [PATCH 07/24] fix(work-orders): restore detail modules and dispatch modal props Reconcile [id]/index with domain-tests base so this shell slice typechecks; keep onDispatched/onUpdated wiring; use woNumber on table rows. --- src/app/(protected)/workorders/[id].tsx | 496 ++++++++++++++++++++++- src/app/(protected)/workorders/index.tsx | 490 ++++++++++++++-------- 2 files changed, 792 insertions(+), 194 deletions(-) diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 9deeb752..fb4d999f 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,20 +1,36 @@ -import { Link as RouterLink, useNavigate } from "react-router"; +import { useMemo, useState } from "react"; +import { Link as RouterLink, useNavigate, useParams } from "react-router"; 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 { useState } from "react"; -import { useParams } from "react-router"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections"; -import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu"; +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, @@ -22,20 +38,167 @@ import { 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 ( + + No comments yet. + + ); + } + + return ( + + {sorted.map((comment) => ( + + + + {comment.userName || comment.commenter || "System"} + + + {comment.createdDate ? formatDateTime(comment.createdDate) : ""} + + + {comment.commenttext} + + ))} + + ); +} + +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 ( + + No changes recorded. + + ); + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + + ))} + + ); +} export default function WorkOrderViewPage() { const { id } = useParams(); const navigate = useNavigate(); const workOrderId = id ?? ""; - const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId); + 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("customer"); + const [commentText, setCommentText] = useState(""); const [dispatchOpen, setDispatchOpen] = useState(false); const [dispatchDetailId, setDispatchDetailId] = useState(null); + const [statusAnchor, setStatusAnchor] = useState(null); + const [assignAnchor, setAssignAnchor] = useState(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; + } + if (activeTab !== "customer" && activeTab !== "internal") { + return; + } + + commentMutation.mutate( + { text: commentText.trim(), commentType: activeTab }, + { onSuccess: () => setCommentText("") }, + ); + }; if (isLoading) { return ( @@ -61,9 +224,14 @@ export default function WorkOrderViewPage() { Work Orders - {wo.internalWONumber} + + {wo.internalWONumber} + - {wo.title} - statusMutation.mutate(status)} + setStatusAnchor(event.currentTarget)} + className="w-fit cursor-pointer" /> + setStatusAnchor(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + setStatusAnchor(null); + if (status !== wo.status) { + statusMutation.mutate(status); + } + }} + > + {status} + + ))} + - + @@ -94,27 +290,283 @@ export default function WorkOrderViewPage() { + + + Details + + + + + + + + + Assigned To + + + setAssignAnchor(null)} + > + { + setAssignAnchor(null); + assignmentMutation.mutate({ userId: "", userName: "" }); + }} + > + Unassigned + + {users.map((user) => ( + { + setAssignAnchor(null); + assignmentMutation.mutate({ userId: user.id, userName: user.name }); + }} + > + {user.name} + + ))} + + + + + + + + }> + + Classification + + + + + + + + + + + + }> + + Schedule + + + + + + + + + + + + + }> + + Financial + + + + + + + + + + + + }> + + Dispatches ({wo.dispatches.length}) + + + + {wo.dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + + + + {wo.dispatches.map((dispatch) => { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + return ( + setDispatchDetailId(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + {dispatch.status === "Sent" ? ( + + {timeSince(dispatch.dispatchedAt)} + + ) : ( + "—" + )} + + + ); + })} + +
+
+ ) : ( + + No dispatches yet. + + )} +
+
+ + }> + + Description + + + + {wo.description || "No description provided."} + + + + + Activity + + setActiveTab(value)} + className="mb-3" + > + + + + - assignmentMutation.mutate({ userId, userName })} - onDispatchClick={setDispatchDetailId} - onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })} - isCommentPending={commentMutation.isPending} - /> + {activeTab === "customer" && } + {activeTab === "internal" && } + {activeTab === "audit" && } + {activeTab === "internal" && ( + + setCommentText(event.target.value)} + placeholder="Type a comment..." + disabled={commentMutation.isPending} + /> + + + )} + {dispatchOpen && ( - setDispatchOpen(false)} /> + setDispatchOpen(false)} + onDispatched={() => void refetch()} + /> )} - {dispatchDetailId && ( setDispatchDetailId(null)} + onUpdated={() => void refetch()} /> )} ); } + +function Field({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 98de7ec7..dd5b0df8 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,191 +1,337 @@ import { useMemo, useState } from "react"; -import { Box } from "@mui/material"; -import { PageHeader } from "@/components/ui/page-header"; -import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; -import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; -import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; -import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; -import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; -import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; -import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; -import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; -import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; -import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; -import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { useNavigate } from "react-router"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; import { - useWorkOrderLocations, - useWorkOrderVendors, -} from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled"; -import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data"; -import type { CompDocData } from "@/domain/work-orders/types/completion-doc"; -import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -import { useAuthContext } from "@/providers/auth-context"; + 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 { WorkOrderTableRow } 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 { user } = useAuthContext(); - const [wizardOpen, setWizardOpen] = useState(false); - const [slideOverRow, setSlideOverRow] = useState(null); - const [slideOverEdit, setSlideOverEdit] = useState(false); - const [docRow, setDocRow] = useState(null); - const [mediaRow, setMediaRow] = useState(null); - const [completionDocs, setCompletionDocs] = useState>({}); - const [mediaFiles, setMediaFiles] = useState>({}); - const filters = useWorkOrdersListFilters(user?.id); - const tableData = useWorkOrdersTableData(filters); - const { patchField } = useWorkOrderTableMutations(tableData.patchRow); + 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 { data: locations = [] } = useWorkOrderLocations(); - const { data: vendors = [] } = useWorkOrderVendors(); - const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const pmTypes = useMemo( - () => - isWoTableMockEnabled() - ? getMockPmTypes() - : problemOptions.map((option) => option.value).filter(Boolean), - [problemOptions], - ); + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; - const vendorTechs = useMemo( - () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], - [vendors], - ); + const handleSort = (column: string) => { + if (sortBy === column) { + setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc")); + if (sortDir === "desc") { + setSortBy(""); + } + return; + } - const totalCount = tableData.totalCount; - const scopeTotalCount = tableData.scopeTotalCount; + setSortBy(column); + setSortDir("asc"); + }; - const activeDocRow = useMemo(() => { - if (!docRow) return null; - return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; - }, [docRow, tableData.rows]); + const sortIndicator = (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : " ↑"; + }; - const activeMediaRow = useMemo(() => { - if (!mediaRow) return null; - return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; - }, [mediaRow, tableData.rows]); - - const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { - setSlideOverRow(row); - setSlideOverEdit(edit); + const handleStatusFilter = (status: string) => { + setStatusFilter((current) => (current === status ? "" : status)); + setPage(0); }; return ( - - filters.setAdvOpen(true)} - onNewWorkOrder={() => setWizardOpen(true)} - activeFilterCount={filters.activeFilterCount} - isFetching={tableData.isFetching && !tableData.isLoading} - /> - } - /> - - - - {filters.advApplied && ( - - )} - - filters.handleSearchChange("")} - onOpenAdvancedFilters={() => filters.setAdvOpen(true)} - /> - - - - - - { - setSlideOverRow(null); - setSlideOverEdit(false); - tableData.setSelectedId(null); + + + + + Work Orders + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search..." + className="min-w-[220px]" + /> + + + + + { + setStatusFilter(""); + setPage(0); + }} + /> + {STATUS_OPTIONS.map((status) => ( + handleStatusFilter(status)} + /> + ))} + + + Assignee + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load work orders"} + + )} + + + + + handleSort("number")} + > + WO #{sortIndicator("number")} + + handleSort("title")} + > + Title{sortIndicator("title")} + + Location + Priority + Status + Created + Assigned To + Last Updated + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No work orders found + + + + ) : ( + rows.map((row: WorkOrderTableRow) => ( + + + + + {row.title} + {row.location} + {row.priority} + + + + + {formatDateTime(row.scheduledOn)} + + {row.tech || "Unassigned"} + {formatDateTime(row.dueDate)} + + + navigate(`/workorders/${row.id}`)} + > + + + + + navigate(`/workorders/edit/${row.id}`)} + > + + + + + + )) + )} + +
+
+ setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} /> - - {activeMediaRow && ( - { - if (!open) setMediaRow(null); - }} - uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} - onChange={(files) => { - setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files })); - patchField(activeMediaRow.id, { mediaCount: files.length }); - }} - /> - )} - - {activeDocRow && ( - { - if (!open) setDocRow(null); - }} - data={completionDocs[String(activeDocRow.id)]} - onSave={(data, status) => { - setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); - patchField(activeDocRow.id, { docStatus: status }); - }} - onStatusChange={(status) => patchField(activeDocRow.id, { status })} - onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })} - /> - )}
); } From 32609659b7270229d06a59981ebd82126598593d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:04:33 -0300 Subject: [PATCH 08/24] feat(work-orders): add board table core and filters --- .../_components/list/table/pm-type-icons.ts | 45 +++ .../_components/list/table/wo-table-cols.ts | 19 + .../_components/list/table/wo-table-head.tsx | 31 ++ .../list/table/wo-table-inline-row.tsx | 288 +++++++++++++++ .../list/table/wo-table-row-actions.tsx | 67 ++++ .../_components/list/table/wo-table-row.tsx | 280 +++++++++++++++ .../list/table/wo-table-section-header.tsx | 144 ++++++++ .../list/table/wo-table-status-style.ts | 23 ++ .../list/table/wo-table-type-style.ts | 14 + .../_components/list/table/wo-table-types.ts | 3 + .../_components/list/table/wo-table.tsx | 329 ++++++++++++++++++ .../workorders/_hooks/use-table-sort.ts | 58 +++ .../_hooks/use-work-orders-table-data.ts | 222 ++++++++++++ 13 files changed, 1523 insertions(+) create mode 100644 src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-types.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-table-sort.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts diff --git a/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts b/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts new file mode 100644 index 00000000..50539aca --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/pm-type-icons.ts @@ -0,0 +1,45 @@ +import { createElement, type ComponentProps } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangle, + Droplets, + Filter, + Gauge, + Lightbulb, + Lock, + Shield, + ShieldCheck, + Thermometer, + Video, + Waves, + Wind, + Wrench, + Zap, +} from "lucide-react"; + +export const PM_TYPE_ICONS: Record = { + "Scrubber Pit": Filter, + "Backflow Preventers": Shield, + "Domestic Backflow": ShieldCheck, + "Tank / Water Heater": Thermometer, + "Pressure Reducing Valve": Gauge, + "Pumping Systems": Waves, + "HVAC Inspection": Wind, + Electrical: Zap, + Plumbing: Wrench, + "Door / Hardware": Lock, + "Floor Drain Backing Up": Droplets, + "Parking Lot Lighting": Lightbulb, + "Mens Restroom Camera Line": Video, + "Assess Window Leaks": AlertTriangle, +}; + +export function getPmTypeIcon(pmType: string): LucideIcon { + return PM_TYPE_ICONS[pmType] ?? Wrench; +} + +type PmTypeIconProps = ComponentProps & { pmType: string }; + +export function PmTypeIcon({ pmType, ...props }: PmTypeIconProps) { + return createElement(getPmTypeIcon(pmType), props); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts new file mode 100644 index 00000000..837c8622 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -0,0 +1,19 @@ +export const WO_TABLE_COLS = [ + { key: "grip", label: "", w: 28 }, + { key: "flag", label: "", w: 28 }, + { key: "site", label: "SITE", w: 168 }, + { key: "wo", label: "WO", w: 120 }, + { key: "type", label: "TYPE OF WO", w: 108 }, + { key: "assigned", label: "ASSIGNED TO", w: 136 }, + { key: "sched", label: "SCHEDULE ON", w: 112 }, + { key: "due", label: "DUE DATE", w: 112 }, + { key: "pm", label: "SERVICE", w: 168 }, + { key: "vendor", label: "VENDOR", w: 184 }, + { key: "appt", label: "APPT TIME", w: 128 }, + { key: "status", label: "STATUS", w: 148 }, + { key: "doc", label: "COMP DOC", w: 84 }, + { key: "actions", label: "", w: 100 }, +] as const; + +export const WO_TABLE_MIN_WIDTH = 1504; +export const WO_TABLE_ROW_HEIGHT = 52; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx new file mode 100644 index 00000000..36309923 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx @@ -0,0 +1,31 @@ +import { WO_TABLE_COLS } from "./wo-table-cols"; + +export function WoTableHead() { + return ( + + + {WO_TABLE_COLS.map((col) => ( + + {col.label} + + ))} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx new file mode 100644 index 00000000..3340e24a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -0,0 +1,288 @@ +import { Check, Flag, X } from "lucide-react"; + +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; + +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; + +import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; + +import { AssignedToCell } from "./cells/assigned-to-cell"; + +import { ApptTimeCell } from "./cells/appt-time-cell"; + +import { DateCell } from "./cells/date-cell"; + +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; + +import { PMTypeCell } from "./cells/pm-type-cell"; + +import { SiteCell } from "./cells/site-cell"; + +import { StatusCell } from "./cells/status-cell"; + +import { TypeCell } from "./cells/type-cell"; + +import { VendorCell } from "./cells/vendor-cell"; + +import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; + +type WoTableInlineRowProps = { + draft: WorkOrderTableDraft; + + draftDayKey: string; + + users: WorkOrderUser[]; + + sites: LocationOption[]; + + vendors: VendorDropdownItem[]; + + pmTypes: string[]; + + allRows: { id: string | number; woNumber: string }[]; + + onChange: (draft: WorkOrderTableDraft) => void; + + onSave: () => void; + + onCancel: () => void; +}; + +export function WoTableInlineRow({ + draft, + + draftDayKey, + + users, + + sites, + + vendors, + + pmTypes, + + allRows, + + onChange, + + onSave, + + onCancel, +}: WoTableInlineRowProps) { + const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; + + const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn; + + const patch = (p: Partial) => onChange({ ...draft, ...p }); + + const draftRow = { + id: "draft", + + woNumber: draft.woNumber, + + status: draft.status, + + isPastDue: false, + + type: draft.type, + + rescheduleCount: 0, + + carriedOver: 0, + + docStatus: "" as const, + + mediaCount: 0, + + site: draft.site, + + location: draft.location, + + dispatcherId: draft.dispatcherId, + + scheduledOn: draft.scheduledOn, + + dueDate: draft.dueDate, + + pm: draft.pm, + + company: draft.company, + + tech: draft.tech, + + targetWeek: draft.targetWeek, + }; + + return ( + + + + + + + + + + patch({ + site: p.site, + + location: p.location, + + locationId: p.locationId, + + pocName: p.pocName, + + pocPhone: p.pocPhone, + + pocNotes: p.pocNotes, + }) + } + /> + + + + patch({ woNumber: v })} + /> + + + + patch({ type: t })} /> + + + + { + patch({ dispatcherId: id, status: draft.status || "Incomplete" }); + }} + /> + + + + patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })} + onSaveWeek={(week) => + patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true }) + } + /> + + + + patch({ dueDate: date })} /> + + + + patch({ pm })} /> + + + + patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })} + /> + + + + { + const [start, end] = v.split(" – "); + + patch({ apptStart: start ?? "", apptEnd: end ?? "" }); + }} + /> + + + + patch({ status })} + onOpenSlideOver={() => {}} + /> + + + + — + + + +
+ + + +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx new file mode 100644 index 00000000..c6ceaf10 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx @@ -0,0 +1,67 @@ +import { Eye, Pencil } from "lucide-react"; + +type WoTableRowActionsProps = { + onView: () => void; + + onEdit: () => void; +}; + +export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) { + const mk = ( + Icon: React.ElementType, + + title: string, + + onClick: () => void, + + hoverBg: string, + + hoverColor: string, + ) => ( + + ); + + return ( +
+ {mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")} + + {mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx new file mode 100644 index 00000000..f3210b61 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -0,0 +1,280 @@ +import { GripVertical } from "lucide-react"; +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { + applySchedulePatch, + maybeAutoSchedulePatch, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { AssignedToCell } from "./cells/assigned-to-cell"; +import { ApptTimeCell } from "./cells/appt-time-cell"; +import { CompDocCell } from "./cells/comp-doc-cell"; +import { DateCell } from "./cells/date-cell"; +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; +import { FlagCell } from "./cells/flag-cell"; +import { PMTypeCell } from "./cells/pm-type-cell"; +import { SiteCell } from "./cells/site-cell"; +import { StatusCell } from "./cells/status-cell"; +import { TypeCell } from "./cells/type-cell"; +import { VendorCell } from "./cells/vendor-cell"; +import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; +import { WoTableRowActions } from "./wo-table-row-actions"; + +export type WoTableRowHandlers = { + onRowClick: (row: WorkOrderTableRow) => void; + onView: (row: WorkOrderTableRow) => void; + onEdit: (row: WorkOrderTableRow) => void; + onOpenCompDoc: (row: WorkOrderTableRow) => void; + onOpenMedia: (row: WorkOrderTableRow) => void; + onPatch: (id: string | number, patch: Partial) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; + onOpenSlideOver: (row: WorkOrderTableRow) => void; + onPastDueAttempt?: (id: string | number) => void; + onReorder: (dragId: string | number, overId: string | number) => void; + onDragStart: (id: string | number) => void; + onDragOver: (id: string | number) => void; + onDrop: () => void; + onDragEnd: () => void; + setFlag: (id: string | number, color: string | null) => void; +}; + +type WoTableRowProps = { + row: WorkOrderTableRow; + allRows: WorkOrderTableRow[]; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + search?: string; + forceScheduleHighlight?: boolean; + flagColor?: string; + isSelected: boolean; + isFlashing: boolean; + dragViz: DragVizState; + handlers: WoTableRowHandlers; +}; + +export function WoTableRow({ + row, + allRows, + users, + sites, + vendors, + pmTypes, + search, + forceScheduleHighlight, + flagColor, + isSelected, + isFlashing, + dragViz, + handlers, +}: WoTableRowProps) { + const isDragging = dragViz.dragId === row.id; + const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id; + const isCanceled = (row.status as string) === "Canceled"; + + let firstCellShadow = "none"; + if (row.type === "Emergency") { + firstCellShadow = "inset 3px 0 0 var(--destructive)"; + } else if (row.isPastDue) { + firstCellShadow = "inset 3px 0 0 var(--warning)"; + } + + const patchAndMaybeAuto = (patch: Partial) => { + const merged = { ...row, ...patch }; + const auto = maybeAutoSchedulePatch(merged); + handlers.onPatch(row.id, { ...patch, ...auto }); + }; + + return ( + handlers.onRowClick(row)} + onDragStart={() => handlers.onDragStart(row.id)} + onDragOver={(e) => { + e.preventDefault(); + handlers.onDragOver(row.id); + }} + onDrop={(e) => { + e.preventDefault(); + handlers.onDrop(); + }} + onDragEnd={handlers.onDragEnd} + className="group/row cursor-pointer transition-colors" + style={{ + height: WO_TABLE_ROW_HEIGHT, + borderBottom: "1px solid var(--border)", + borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined, + opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1, + pointerEvents: isCanceled ? "none" : undefined, + background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)", + outline: isFlashing ? "2px solid var(--color-primary)" : "none", + outlineOffset: isFlashing ? "-2px" : undefined, + borderRadius: isFlashing ? 4 : undefined, + transition: "background 1.8s ease-out, outline 1.8s ease-out", + }} + onMouseEnter={(e) => { + if (!isSelected && !isFlashing) { + (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)"; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isFlashing) { + (e.currentTarget as HTMLTableRowElement).style.background = "var(--card)"; + } + }} + > + e.stopPropagation()} + > + + + + + e.stopPropagation()}> + handlers.setFlag(row.id, c)} /> + + e.stopPropagation()}> + + handlers.onPatch(row.id, { + site: p.site, + location: p.location, + locationId: p.locationId, + pocName: p.pocName, + pocPhone: p.pocPhone, + pocNotes: p.pocNotes, + }) + } + /> + + + handlers.onPatch(row.id, { woNumber: v })} + onDuplicateFound={handlers.onDuplicateFound} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { type: t })} + /> + + e.stopPropagation()}> + patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })} + /> + + e.stopPropagation()}> + patchAndMaybeAuto(applySchedulePatch(date, false))} + onSaveWeek={(week) => + patchAndMaybeAuto({ + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + dayGroup: "", + dayLabel: "", + }) + } + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { dueDate: date })} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { pm })} + /> + + e.stopPropagation()}> + + handlers.onPatch(row.id, { + company: p.company, + tech: p.tech, + techPhone: p.techPhone, + }) + } + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { apptTime: v })} + /> + + e.stopPropagation()}> + handlers.onPatch(row.id, { status })} + onOpenSlideOver={() => handlers.onOpenSlideOver(row)} + onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} + /> + + e.stopPropagation()}> + handlers.onOpenCompDoc(row)} + onOpenMedia={() => handlers.onOpenMedia(row)} + /> + + e.stopPropagation()}> + handlers.onView(row)} + onEdit={() => handlers.onEdit(row)} + /> + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx new file mode 100644 index 00000000..18301594 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -0,0 +1,144 @@ +import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react"; +import { WO_TABLE_COLS } from "./wo-table-cols"; + +type WoTableSectionHeaderProps = { + variant: "unscheduled" | "day"; + label: string; + count: number; + isToday?: boolean; + collapsed?: boolean; + onToggleCollapse?: () => void; + onAddWo: () => void; +}; + +export function WoTableSectionHeader({ + variant, + label, + count, + isToday, + collapsed, + onToggleCollapse, + onAddWo, +}: WoTableSectionHeaderProps) { + const isUnscheduled = variant === "unscheduled"; + + if (isUnscheduled) { + return ( + + +
+ + + {label} + + + {count} work order{count !== 1 ? "s" : ""} + + + + {collapsed ? : } + +
+ + + ); + } + + return ( + + +
+ + {label} + + + {count} WO{count !== 1 ? "s" : ""} + + {isToday && ( + + Today + + )} + +
+ + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts new file mode 100644 index 00000000..f23d8fbd --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-status-style.ts @@ -0,0 +1,23 @@ +import type { StatusVariant } from "@/components/domain/status-badge"; +import { resolveStatusVariant } from "@/components/domain/status-badge-utils"; + +export type WoTableStatusStyle = { text: string; dot: string }; + +const STATUS_TABLE_STYLE: Record = { + incomplete: { text: "var(--status-incomplete-text)", dot: "#9CA3AF" }, + pending: { text: "var(--status-pending-text)", dot: "#D97706" }, + scheduled: { text: "var(--status-scheduled-text)", dot: "#3B82F6" }, + inroute: { text: "var(--status-inroute-text)", dot: "#0EA5E9" }, + onsite: { text: "var(--status-onsite-text)", dot: "#10B981" }, + inprogress: { text: "var(--status-inprogress-text)", dot: "#2563EB" }, + completed: { text: "var(--status-completed-text)", dot: "#16A34A" }, + rescheduled: { text: "var(--status-rescheduled-text)", dot: "#F59E0B" }, + canceled: { text: "var(--status-canceled-text)", dot: "#9CA3AF" }, + pendingquote: { text: "var(--status-pendingquote-text)", dot: "#9333EA" }, + pastdue: { text: "var(--status-pastdue-text)", dot: "#BE123C" }, +}; + +export function getWoTableStatusStyle(status: string): WoTableStatusStyle { + const variant = resolveStatusVariant(status) ?? "incomplete"; + return STATUS_TABLE_STYLE[variant]; +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts new file mode 100644 index 00000000..23819e2a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts @@ -0,0 +1,14 @@ +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +export const WO_TABLE_TYPE_TEXT: Record = { + Emergency: "var(--destructive)", + Reactive: "#e0701a", + PM: "var(--primary)", + "Add-On": "#7c3aed", + Overdue: "#9F1239", +}; + +export function getWoTableTypeColor(type: WOType | ""): string { + if (!type) return "var(--color-text-muted)"; + return WO_TABLE_TYPE_TEXT[type]; +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts new file mode 100644 index 00000000..2bdf0152 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-types.ts @@ -0,0 +1,3 @@ +import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; + +export type ReturnTypeOfUseWorkOrdersTableData = ReturnType; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx new file mode 100644 index 00000000..f97c3071 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -0,0 +1,329 @@ +import { Fragment, useMemo, useState } from "react"; +import { CircularProgress } from "@mui/material"; +import { SearchX } from "lucide-react"; +import type { + LocationOption, + VendorDropdownItem, + WorkOrderUser, +} from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; +import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; +import { WoTableHead } from "./wo-table-head"; +import { WoTableInlineRow } from "./wo-table-inline-row"; +import { WoTableRow } from "./wo-table-row"; +import { WoTableSectionHeader } from "./wo-table-section-header"; +import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols"; + +const CARD_STYLE: React.CSSProperties = { + background: "var(--card)", + border: "1px solid var(--border)", + borderRadius: 8, + boxShadow: "0 1px 3px rgba(38,34,98,.05)", +}; + +type WoTableProps = { + tableData: ReturnTypeOfUseWorkOrdersTableData; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; + onOpenCompDoc: (row: WorkOrderTableRow) => void; + onOpenMedia: (row: WorkOrderTableRow) => void; + onClearSearch?: () => void; + onOpenAdvancedFilters?: () => void; +}; + +function WoTableShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function WoTableEmptyState({ + message, + actionLabel, + onAction, +}: { + message: string; + actionLabel: string; + onAction?: () => void; +}) { + return ( +
+ +

{message}

+ {onAction && ( + + )} +
+ ); +} + +export function WoTable({ + tableData, + users, + sites, + vendors, + pmTypes, + onOpenSlideOver, + onOpenCompDoc, + onOpenMedia, + onClearSearch, + onOpenAdvancedFilters, +}: WoTableProps) { + const [scheduleHighlightId, setScheduleHighlightId] = useState(null); + const { patchField } = useWorkOrderTableMutations(tableData.patchRow); + const createMutation = useCreateWorkOrderFromWizard(() => { + tableData.cancelDraft(); + }); + const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData; + + const handlers = useMemo( + () => ({ + onRowClick: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, false); + }, + onView: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, false); + }, + onEdit: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenSlideOver(row, true); + }, + onOpenCompDoc: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenCompDoc(row); + }, + onOpenMedia: (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenMedia(row); + }, + onPatch: (id: string | number, patch: Partial) => { + patchField(id, patch); + }, + onDuplicateFound: (existing: WorkOrderTableRow) => { + tableData.setSelectedId(existing.id); + onOpenSlideOver(existing, false); + }, + onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), + onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id), + onReorder: reorderRows, + onDragStart: (id: string | number) => { + dragStateRef.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragStateRef.current.dragId; + if (did && did !== id && dragStateRef.current.overId !== id) { + dragStateRef.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragStateRef.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + setFlag, + }), + [ + tableData, + dragStateRef, + setDragViz, + reorderRows, + endDrag, + setFlag, + onOpenSlideOver, + onOpenCompDoc, + onOpenMedia, + patchField, + ], + ); + + const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( + + ); + + const handleSaveDraft = () => { + const d = tableData.draft; + const assignee = users.find((u) => String(u.id) === d.dispatcherId); + const formValues = mapWizardDraftToFormValues({ + locationId: String(d.locationId || ""), + locationName: d.location || d.site, + woNumber: d.woNumber, + type: d.type, + assigneeId: d.dispatcherId, + assigneeName: assignee?.name ?? "", + scheduledOn: d.scheduledOn, + dueDate: d.dueDate, + pm: d.pm, + vendorId: "", + vendorName: d.company, + techPhone: d.techPhone, + vendorNotes: "", + apptStart: d.apptStart, + apptEnd: d.apptEnd, + status: d.status || "Incomplete", + pocName: d.pocName, + pocPhone: d.pocPhone, + pocNotes: d.pocNotes, + scheduleWeekOnly: d.scheduleWeekOnly, + }); + createMutation.mutate(formValues); + }; + + if (tableData.isLoading) { + return ( + +
+ +
+
+ ); + } + + const emptyAdv = tableData.advActive && tableData.advResults.length === 0; + + if (emptyAdv) { + return ( + + + + ); + } + + if (!tableData.advActive && tableData.noSearchResults) { + return ( + + + + ); + } + + return ( + + + + + {tableData.advActive ? ( + tableData.advResults.map((row) => renderRow(row)) + ) : ( + <> + {(tableData.unscheduled.length > 0 || + tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && ( + <> + tableData.setUnschedOpen((v) => !v)} + onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)} + /> + {tableData.unschedOpen && ( + <> + {tableData.draftDayKey === UNSCHEDULED_DAY_KEY && ( + + )} + {tableData.unscheduled.map((row) => renderRow(row, true))} + + )} + + )} + {tableData.displayGroups.map((group) => ( + + tableData.startDayDraft(group.key)} + /> + {tableData.draftDayKey === group.key && ( + + )} + {group.items.map((row) => renderRow(row))} + + ))} + + )} + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-table-sort.ts b/src/app/(protected)/workorders/_hooks/use-table-sort.ts new file mode 100644 index 00000000..76bb8970 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-table-sort.ts @@ -0,0 +1,58 @@ +import { useCallback, useState } from "react"; +import type { + SortDirection, + WorkOrdersSortableColumn, +} from "@/domain/work-orders/types/work-order-list"; + +type TableSortState = { + column: WorkOrdersSortableColumn | ""; + direction: SortDirection; +}; + +export function useTableSort(initialColumn: WorkOrdersSortableColumn | "" = "") { + const [sortBy, setSortBy] = useState(initialColumn); + const [sortDir, setSortDir] = useState(""); + + const handleSort = useCallback((column: WorkOrdersSortableColumn | string) => { + const col = column as WorkOrdersSortableColumn; + setSortBy((currentColumn) => { + if (currentColumn !== col) { + setSortDir("asc"); + return col; + } + + setSortDir((currentDir) => { + if (currentDir === "asc") { + return "desc"; + } + if (currentDir === "desc") { + setSortBy(""); + return ""; + } + return "asc"; + }); + + return currentColumn; + }); + }, []); + + const sortIndicator = useCallback( + (column: string) => { + if (sortBy !== column) { + return ""; + } + return sortDir === "desc" ? " ↓" : sortDir === "asc" ? " ↑" : ""; + }, + [sortBy, sortDir], + ); + + const sort: TableSortState = { column: sortBy, direction: sortDir }; + + return { + sortBy, + sortDir, + sort, + handleSort, + sortIndicator, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts new file mode 100644 index 00000000..f36b6ab3 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -0,0 +1,222 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { + EMPTY_TABLE_DRAFT, + UNSCHEDULED_DAY_KEY, + type WorkOrderTableDraft, + type WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; +import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + buildDayGroups, + enrichTableRow, + filterByBarAndSearch, + matchesAdvancedFilters, + splitScheduledUnscheduled, +} from "@/domain/work-orders/utils/table-filter-utils"; + +type FiltersReturn = ReturnType; + +export type DragVizState = { + dragId: string | number | null; + overId: string | number | null; +}; + +export function useWorkOrdersTableData(filters: FiltersReturn) { + const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams); + const { data: scopeData } = useWorkOrdersList(filters.scopeListParams); + + const [patches, setPatches] = useState>>({}); + const [localRows, setLocalRows] = useState([]); + const [rowOrder, setRowOrder] = useState(null); + const [flags, setFlagsState] = useState>({}); + const [draftDayKey, setDraftDayKey] = useState(null); + const [draft, setDraft] = useState(EMPTY_TABLE_DRAFT); + const [unschedOpen, setUnschedOpen] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [flashId, setFlashId] = useState(null); + const [dragViz, setDragViz] = useState({ dragId: null, overId: null }); + const dragState = useRef({ dragId: null, overId: null }); + + const apiRows = useMemo( + () => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })), + [data?.items, patches], + ); + + const displayRows = useMemo(() => { + const apiIds = new Set(apiRows.map((r) => String(r.id))); + const extra = localRows.filter((r) => !apiIds.has(String(r.id))); + let merged = [...apiRows, ...extra.map(enrichTableRow)]; + if (rowOrder?.length) { + const orderMap = new Map(rowOrder.map((id, idx) => [id, idx])); + merged = [...merged].sort((a, b) => { + const ai = orderMap.get(String(a.id)) ?? 9999; + const bi = orderMap.get(String(b.id)) ?? 9999; + return ai - bi; + }); + } + return merged; + }, [apiRows, localRows, rowOrder]); + + const advActive = filters.advApplied !== null; + const advResults = useMemo(() => { + if (!filters.advApplied) { + return []; + } + return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!)); + }, [displayRows, filters.advApplied]); + + const filtered = useMemo(() => { + if (advActive) { + return []; + } + return filterByBarAndSearch(displayRows, filters.barFilters, filters.search); + }, [displayRows, filters.barFilters, filters.search, advActive]); + + const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]); + + const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); + const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); + + const searchActive = filters.search.trim() !== ""; + const noSearchResults = filtered.length === 0 && searchActive; + const isEmptyWeek = scheduled.length === 0 && !searchActive; + const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups; + + const todayKey = + filters.weekMonday === currentWeekMonday() + ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup + : null; + + const selectedRow = useMemo( + () => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null, + [displayRows, selectedId], + ); + + const setFlag = useCallback((id: string | number, color: string | null) => { + setFlagsState((prev) => { + const key = String(id); + if (!color) { + const next = { ...prev }; + delete next[key]; + return next; + } + return { ...prev, [key]: color }; + }); + }, []); + + const patchRow = useCallback((id: string | number, patch: Partial) => { + const key = String(id); + setPatches((prev) => { + const merged = { ...prev[key], ...patch }; + if (patch.scheduledOn !== undefined) { + const dayFields = deriveDayFields(patch.scheduledOn); + merged.dayGroup = dayFields.dayGroup; + merged.dayLabel = dayFields.dayLabel; + } + return { ...prev, [key]: merged }; + }); + setLocalRows((prev) => + prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)), + ); + }, []); + + const reorderRows = useCallback( + (dragId: string | number, overId: string | number) => { + const dragKey = String(dragId); + const overKey = String(overId); + const dragRow = displayRows.find((r) => String(r.id) === dragKey); + const overRow = displayRows.find((r) => String(r.id) === overKey); + if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) { + return; + } + const ids = displayRows.map((r) => String(r.id)); + const dragIdx = ids.indexOf(dragKey); + const overIdx = ids.indexOf(overKey); + if (dragIdx < 0 || overIdx < 0) { + return; + } + const next = [...ids]; + next.splice(dragIdx, 1); + next.splice(overIdx, 0, dragKey); + setRowOrder(next); + }, + [displayRows], + ); + + const endDrag = useCallback(() => { + dragState.current = { dragId: null, overId: null }; + setDragViz({ dragId: null, overId: null }); + }, []); + + const startDayDraft = useCallback( + (key: string) => { + setDraftDayKey(key); + const scheduledOn = + key === UNSCHEDULED_DAY_KEY + ? "" + : (() => { + const [mm, dd] = key.split("/").map(Number); + const year = filters.weekMonday.slice(0, 4); + return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; + })(); + setDraft({ + ...EMPTY_TABLE_DRAFT, + scheduledOn, + scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY, + targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "", + }); + }, + [filters.weekMonday], + ); + + const cancelDraft = useCallback(() => { + setDraftDayKey(null); + setDraft(EMPTY_TABLE_DRAFT); + }, []); + + const addRow = useCallback((row: WorkOrderTableRow) => { + setLocalRows((prev) => [...prev, enrichTableRow(row)]); + setFlashId(row.id); + setTimeout(() => setFlashId(null), 2000); + }, []); + + return { + rows: displayRows, + isLoading, + isFetching, + advActive, + advResults, + unscheduled, + displayGroups, + searchActive, + noSearchResults, + isEmptyWeek, + todayKey, + flags, + setFlag, + draftDayKey, + draft, + setDraft, + unschedOpen, + setUnschedOpen, + selectedId, + setSelectedId, + selectedRow, + flashId, + dragViz, + setDragViz, + dragState, + patchRow, + reorderRows, + endDrag, + startDayDraft, + cancelDraft, + addRow, + scopeTotalCount: scopeData?.totalCount ?? 0, + totalCount: data?.totalCount ?? displayRows.length, + search: filters.search, + }; +} From fef7918f6bedda2a28e976f4982ab28dee3c767b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 10:18:27 -0300 Subject: [PATCH 09/24] fix(work-orders): allow read-only open for canceled board rows --- .../list/table/wo-table-row-actions.tsx | 4 +- .../_components/list/table/wo-table-row.tsx | 122 ++++++++++++------ 2 files changed, 83 insertions(+), 43 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx index c6ceaf10..2ec9da42 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-actions.tsx @@ -3,7 +3,7 @@ import { Eye, Pencil } from "lucide-react"; type WoTableRowActionsProps = { onView: () => void; - onEdit: () => void; + onEdit?: () => void; }; export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) { @@ -61,7 +61,7 @@ export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) {
{mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")} - {mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")} + {onEdit ? mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)") : null}
); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index f3210b61..b20e4e60 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -85,23 +85,37 @@ export function WoTableRow({ } const patchAndMaybeAuto = (patch: Partial) => { + if (isCanceled) return; const merged = { ...row, ...patch }; const auto = maybeAutoSchedulePatch(merged); handlers.onPatch(row.id, { ...patch, ...auto }); }; + const patchRow = (patch: Partial) => { + if (isCanceled) return; + handlers.onPatch(row.id, patch); + }; + + /** Block mutation controls on canceled rows while keeping row/View clickable. */ + const mutationCellStyle = isCanceled ? ({ pointerEvents: "none" } as const) : undefined; + return ( handlers.onRowClick(row)} - onDragStart={() => handlers.onDragStart(row.id)} + onDragStart={() => { + if (isCanceled) return; + handlers.onDragStart(row.id); + }} onDragOver={(e) => { + if (isCanceled) return; e.preventDefault(); handlers.onDragOver(row.id); }} onDrop={(e) => { + if (isCanceled) return; e.preventDefault(); handlers.onDrop(); }} @@ -112,7 +126,6 @@ export function WoTableRow({ borderBottom: "1px solid var(--border)", borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined, opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1, - pointerEvents: isCanceled ? "none" : undefined, background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)", outline: isFlashing ? "2px solid var(--color-primary)" : "none", outlineOffset: isFlashing ? "-2px" : undefined, @@ -131,23 +144,34 @@ export function WoTableRow({ }} > e.stopPropagation()} > - e.stopPropagation()}> - handlers.setFlag(row.id, c)} /> + e.stopPropagation()} + > + handlers.setFlag(row.id, c)} + /> - e.stopPropagation()}> + e.stopPropagation()} + > - handlers.onPatch(row.id, { + patchRow({ site: p.site, location: p.location, locationId: p.locationId, @@ -169,7 +193,7 @@ export function WoTableRow({ } /> - + handlers.onPatch(row.id, { woNumber: v })} + onSave={(v) => patchRow({ woNumber: v })} onDuplicateFound={handlers.onDuplicateFound} /> - e.stopPropagation()}> - handlers.onPatch(row.id, { type: t })} - /> + e.stopPropagation()} + > + patchRow({ type: t })} /> - e.stopPropagation()}> + e.stopPropagation()} + > patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })} /> - e.stopPropagation()}> + e.stopPropagation()} + > - e.stopPropagation()}> - handlers.onPatch(row.id, { dueDate: date })} - /> + e.stopPropagation()} + > + patchRow({ dueDate: date })} /> - e.stopPropagation()}> - handlers.onPatch(row.id, { pm })} - /> + e.stopPropagation()} + > + patchRow({ pm })} /> - e.stopPropagation()}> + e.stopPropagation()} + > - handlers.onPatch(row.id, { + patchRow({ company: p.company, tech: p.tech, techPhone: p.techPhone, @@ -246,16 +281,21 @@ export function WoTableRow({ } /> - e.stopPropagation()}> - handlers.onPatch(row.id, { apptTime: v })} - /> + e.stopPropagation()} + > + patchRow({ apptTime: v })} /> - e.stopPropagation()}> + e.stopPropagation()} + > handlers.onPatch(row.id, { status })} + onChangeStatus={(status) => patchRow({ status })} onOpenSlideOver={() => handlers.onOpenSlideOver(row)} onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} /> @@ -272,7 +312,7 @@ export function WoTableRow({ e.stopPropagation()}> handlers.onView(row)} - onEdit={() => handlers.onEdit(row)} + onEdit={isCanceled ? undefined : () => handlers.onEdit(row)} /> From 0709bd11728dbc01880e90bd1764329c974b66c3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 13:19:49 -0300 Subject: [PATCH 10/24] fix(work-orders): include table cells so board-core slice builds Bring cell modules and minimal wizard helpers into table-core for a self-contained slice build. --- .../list/table/cells/appt-time-cell.tsx | 126 ++++++ .../list/table/cells/assigned-to-cell.tsx | 99 ++++ .../list/table/cells/comp-doc-cell.tsx | 67 +++ .../list/table/cells/date-cell.tsx | 139 ++++++ .../list/table/cells/dispatcher-pill.tsx | 40 ++ .../table/cells/editable-wo-number-cell.tsx | 212 +++++++++ .../list/table/cells/flag-cell.tsx | 99 ++++ .../list/table/cells/highlight-text.tsx | 26 ++ .../list/table/cells/hover-chevron.tsx | 11 + .../list/table/cells/pm-type-cell.tsx | 120 +++++ .../list/table/cells/site-cell.tsx | 79 ++++ .../list/table/cells/site-dialog.tsx | 270 +++++++++++ .../list/table/cells/status-cell.tsx | 151 +++++++ .../list/table/cells/type-cell.tsx | 71 +++ .../list/table/cells/vendor-cell.tsx | 57 +++ .../list/table/cells/vendor-dialog.tsx | 268 +++++++++++ .../table/cells/wo-table-status-label.tsx | 25 + .../list/table/cells/wo-table-type-label.tsx | 19 + .../_components/wizard/mini-calendar.tsx | 115 +++++ .../_components/wizard/week-picker.tsx | 108 +++++ .../wizard/wizard-field-select.tsx | 167 +++++++ .../_components/wizard/wizard-input-styles.ts | 9 + .../_components/wizard/wizard-labels.tsx | 180 ++++++++ src/app/(protected)/workorders/index.tsx | 427 +++++------------- .../work-orders/mappers/work-order-mapper.ts | 2 + .../work-orders/types/work-order-common.ts | 2 + 26 files changed, 2569 insertions(+), 320 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/week-picker.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-input-styles.ts create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-labels.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx new file mode 100644 index 00000000..0f2aa025 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { Button, Popover } from "@mui/material"; +import { + calcDuration, + fmt12, + formatApptRange, + formatApptTimeValue, + parseApptTime, + TIME_SLOTS, +} from "@/domain/work-orders/utils/wizard-date-utils"; +import { HoverChevron } from "./hover-chevron"; + +type ApptTimeCellProps = { + apptTime: string; + onSave: (value: string) => void; +}; + +export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [start, setStart] = useState(() => parseApptTime(apptTime).start); + const [end, setEnd] = useState(() => parseApptTime(apptTime).end); + + useEffect(() => { + const p = parseApptTime(apptTime); + setStart(p.start); + setEnd(p.end); + }, [apptTime]); + + const parsed = parseApptTime(apptTime); + const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime; + + const slotStyle = (active: boolean): React.CSSProperties => ({ + background: active ? "var(--color-primary-tint)" : "transparent", + color: active ? "var(--color-primary)" : "var(--color-text-primary)", + fontWeight: active ? 600 : 400, + }); + + const done = () => { + onSave(formatApptTimeValue(start, end)); + setAnchorEl(null); + }; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }} + > +
+
+

+ Start +

+
+ {TIME_SLOTS.map((t) => ( + + ))} +
+
+
+

+ End (optional) +

+
+ {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( + + ))} +
+
+
+
+ + {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + + +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx new file mode 100644 index 00000000..92557ea2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Check, Users } from "lucide-react"; +import { Popover } from "@mui/material"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { DispatcherPill } from "./dispatcher-pill"; +import { HoverChevron } from "./hover-chevron"; + +type AssignedToCellProps = { + dispatcherId: string; + users: WorkOrderUser[]; + highlight?: boolean; + onSave: (id: string, name: string) => void; +}; + +export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [search, setSearch] = useState(""); + const selected = users.find((d) => String(d.id) === dispatcherId); + const filtered = search + ? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase())) + : users; + + return ( + <> + + { + setAnchorEl(null); + setSearch(""); + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }} + > +
+ setSearch(e.target.value)} + placeholder="Search dispatcher…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
+
+ {filtered.map((d) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx new file mode 100644 index 00000000..55199255 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx @@ -0,0 +1,67 @@ +import { Ban, FileText, Image as ImageIcon } from "lucide-react"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +function isMediaWO(type: WOType | ""): boolean { + return type === "Emergency" || type === "Reactive"; +} + +type CompDocCellProps = { + status: CompletionDocStatus; + woType: WOType | ""; + mediaCount?: number; + onOpen: () => void; + onOpenMedia: () => void; +}; + +export function CompDocCell({ + status, + woType, + mediaCount = 0, + onOpen, + onOpenMedia, +}: CompDocCellProps) { + if (isMediaWO(woType)) { + return ( + + ); + } + + const generated = status === "Yes"; + const notRequired = status === "NN"; + const color = generated ? "var(--color-success)" : "var(--color-text-muted)"; + const Icon = notRequired ? Ban : FileText; + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx new file mode 100644 index 00000000..7eee438c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { Popover } from "@mui/material"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; +import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils"; +import { HighlightText } from "./highlight-text"; + +type DateCellProps = { + value: string; + q?: string; + highlight?: boolean; + onSave: (date: string) => void; + allowWeekMode?: boolean; + targetWeek?: string; + onSaveWeek?: (weekStart: string) => void; + defaultMode?: "week" | "date"; +}; + +export function DateCell({ + value, + q, + highlight, + onSave, + allowWeekMode, + targetWeek = "", + onSaveWeek, + defaultMode, +}: DateCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + ); + const showWeek = allowWeekMode && !value && !!targetWeek; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} + > + {allowWeekMode && ( +
+ {(["week", "date"] as const).map((m) => { + const active = cellMode === m; + return ( + + ); + })} +
+ )} + {allowWeekMode && cellMode === "week" ? ( + { + onSave(""); + onSaveWeek?.(mon); + setAnchorEl(null); + }} + /> + ) : ( + { + onSave(d); + setAnchorEl(null); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx new file mode 100644 index 00000000..77ac64e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -0,0 +1,40 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; + +import { HighlightText } from "./highlight-text"; + +type DispatcherPillProps = { + user: WorkOrderUser; + + q?: string; +}; + +export function DispatcherPill({ user, q }: DispatcherPillProps) { + const color = getDispatcherAvatarColor(user); + + return ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx new file mode 100644 index 00000000..ce4b3462 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from "react"; + +import { toast } from "react-toastify"; + +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +import { HighlightText } from "./highlight-text"; + +type EditableWONumberCellProps = { + value: string; + + rescheduleCount: number; + + carriedOver?: number; + + q?: string; + + currentId: string | number; + + allOrders: WorkOrderTableRow[]; + + onSave: (v: string) => void; + + onDuplicateFound?: (existing: WorkOrderTableRow) => void; +}; + +function CounterPill({ label, count }: { label: string; count: number }) { + if (count < 2) { + return null; + } + + return ( + + {count} + + ); +} + +export function EditableWONumberCell({ + value, + + rescheduleCount, + + carriedOver = 0, + + q, + + currentId, + + allOrders, + + onSave, + + onDuplicateFound, +}: EditableWONumberCellProps) { + const [editing, setEditing] = useState(false); + + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!editing) { + setDraft(value); + } + }, [value, editing]); + + const findDuplicate = (d: string) => + allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId)); + + const commit = () => { + if (draft === value) { + setEditing(false); + + return; + } + + if (draft.length !== 11) { + toast("Work order number must be exactly 11 digits"); + + return; + } + + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + + setEditing(false); + }; + + const cancel = () => { + setDraft(value); + + setEditing(false); + }; + + const handleBlur = () => { + if (draft !== value && draft.length === 11) { + const duplicate = findDuplicate(draft); + + if (duplicate) { + onDuplicateFound?.(duplicate); + + setDraft(value); + + setEditing(false); + + return; + } + + onSave(draft); + } + + setEditing(false); + }; + + const invalid = editing && draft.length > 0 && draft.length !== 11; + + return ( +
+ {editing ? ( + setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(e) => e.target.select()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + + cancel(); + } + }} + onBlur={handleBlur} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: "var(--foreground)", + + background: "var(--color-bg-surface)", + + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + + borderRadius: 4, + + padding: "2px 4px", + + width: 120, + }} + /> + ) : ( + { + e.stopPropagation(); + + setEditing(true); + }} + className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" + style={{ + fontSize: 13, + + fontWeight: 700, + + color: value ? "var(--foreground)" : "var(--color-text-muted)", + + borderRadius: 4, + + padding: "2px 4px", + }} + > + {value ? : "-"} + + )} + + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx new file mode 100644 index 00000000..cda95c32 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Flag, X } from "lucide-react"; +import { Popover } from "@mui/material"; + +const FLAG_COLORS: { name: string; value: string }[] = [ + { name: "Red", value: "#EF4444" }, + { name: "Orange", value: "#F97316" }, + { name: "Amber", value: "#F59E0B" }, + { name: "Yellow", value: "#EAB308" }, + { name: "Green", value: "#22C55E" }, + { name: "Teal", value: "#14B8A6" }, + { name: "Blue", value: "#3B82F6" }, + { name: "Purple", value: "#8B5CF6" }, +]; + +type FlagCellProps = { + color?: string; + disabled?: boolean; + onChange: (color: string | null) => void; +}; + +export function FlagCell({ color, disabled, onChange }: FlagCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + + if (disabled) { + return ( + + + + ); + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }} + > +
+ {FLAG_COLORS.map((c) => ( + + ))} +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx new file mode 100644 index 00000000..95b10c15 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx @@ -0,0 +1,26 @@ +type HighlightTextProps = { + text: string; + q?: string; +}; + +export function HighlightText({ text, q }: HighlightTextProps) { + if (!q?.trim() || !text) { + return <>{text}; + } + const query = q.trim(); + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx < 0) { + return <>{text}; + } + const before = text.slice(0, idx); + const match = text.slice(idx, idx + query.length); + const after = text.slice(idx + query.length); + return ( + <> + {before} + {match} + {after} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx new file mode 100644 index 00000000..69227086 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx @@ -0,0 +1,11 @@ +import { ChevronDown } from "lucide-react"; + +export function HoverChevron() { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx new file mode 100644 index 00000000..4ae184df --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; + +import { Check } from "lucide-react"; + +import { Popover } from "@mui/material"; + +import { PmTypeIcon } from "../pm-type-icons"; + +import { HighlightText } from "./highlight-text"; + +import { HoverChevron } from "./hover-chevron"; + +type PMTypeCellProps = { + value: string; + + pmTypes: string[]; + + q?: string; + + onSave: (v: string) => void; +}; + +export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + + const [search, setSearch] = useState(""); + + const filtered = search + ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : pmTypes; + + return ( + <> + + + { + setAnchorEl(null); + + setSearch(""); + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }} + > +
+ setSearch(e.target.value)} + placeholder="Search PM type…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
+ +
+ {filtered.map((pm) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx new file mode 100644 index 00000000..204d4f5a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { MapPin } from "lucide-react"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; +import { HighlightText } from "./highlight-text"; +import { HoverChevron } from "./hover-chevron"; +import { SiteDialog, type SitePatch } from "./site-dialog"; + +type SiteCellProps = { + value: string; + location: string; + locationId: string | number; + pocName?: string; + pocPhone?: string; + pocNotes?: string; + sites: LocationOption[]; + q?: string; + onSave: (patch: SitePatch) => void; +}; + +export function SiteCell({ + value, + location, + locationId, + pocName, + pocPhone, + pocNotes, + sites, + q, + onSave, +}: SiteCellProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx new file mode 100644 index 00000000..0fc46d4b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; +import { ExternalLink, Mail, MapPin, Phone } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { Location } from "@/domain/locations/types/location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +export type SitePatch = { + site: string; + location: string; + locationId: string | number; + pocName: string; + pocPhone: string; + pocNotes: string; +}; + +type SiteDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + value: string; + locationId: string | number; + pocName?: string; + pocPhone?: string; + pocNotes?: string; + sites: LocationOption[]; + onSave: (patch: SitePatch) => void; +}; + +function FieldLabel({ children }: { children: ReactNode }) { + return ( + + ); +} + +function extractSiteCode(name: string): string { + const dash = name.indexOf(" — "); + if (dash > 0) { + return name.slice(0, dash).trim(); + } + return name.slice(0, 8).toUpperCase(); +} + +function extractLocationLabel(name: string): string { + const dash = name.indexOf(" — "); + if (dash > 0) { + return name.slice(dash + 3).trim(); + } + return name; +} + +function buildMapsUrl(location: Location): string { + const parts = [location.address, location.city, location.state, location.zipCode] + .filter(Boolean) + .join(", "); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`; +} + +export function SiteDialog({ + open, + onOpenChange, + value, + locationId, + pocName = "", + pocPhone = "", + pocNotes = "", + sites, + onSave, +}: SiteDialogProps) { + const [code, setCode] = useState(value); + const [locId, setLocId] = useState(String(locationId || "")); + const [pn, setPn] = useState(pocName); + const [pp, setPp] = useState(pocPhone); + const [notes, setNotes] = useState(pocNotes); + const [pocFilledFor, setPocFilledFor] = useState(""); + + const isMockLocation = locId.startsWith("mock-"); + const { data: apiLocationDetail } = useLocationDetail( + isMockLocation || !locId ? undefined : locId, + ); + const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail; + + useEffect(() => { + if (open) { + setCode(value); + setLocId(String(locationId || "")); + setPn(pocName); + setPp(pocPhone); + setNotes(pocNotes); + setPocFilledFor(String(locationId || "")); + } + }, [open, value, locationId, pocName, pocPhone, pocNotes]); + + useEffect(() => { + if (!open || !locId || !locationDetail || pocFilledFor === locId) { + return; + } + setPn(locationDetail.contact ?? ""); + setPp(locationDetail.phone ?? ""); + setPocFilledFor(locId); + }, [open, locId, locationDetail, pocFilledFor]); + + const selected = sites.find((s) => String(s.id) === locId); + + const handlePick = (id: string) => { + const next = sites.find((s) => String(s.id) === id); + setLocId(id); + if (next) { + setCode(extractSiteCode(next.name)); + } + if (id !== pocFilledFor) { + setPocFilledFor(""); + } + }; + + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "w-full", + style: { borderRadius: 12, maxWidth: 480 }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + Site + +

Select a site and edit its point of contact

+ +
+
+ Site + + } + searchPlaceholder="Search site code or city…" + options={sites.map((site) => ({ + key: String(site.id), + label: site.name, + }))} + onPick={handlePick} + /> +
+ + {locationDetail && locId && ( +
+ )} + +
+ Point of Contact +

+ Auto-filled from records — edit if outdated +

+
+ setPn(e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + setPp(e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +