chore: upgrade frontend dependencies (#24)
Some checks failed
CI / ci (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

* chore: upgrade frontend application dependencies

* chore: upgrade frontend infrastructure dependencies

* fix: align lockfiles with CI npm

* fix: preserve ky error semantics

* fix: sanitize timeout error message
This commit is contained in:
Alexandre Brandizzi 2026-07-14 21:35:59 -03:00 • committed by GitHub
parent 166df904ac
commit c68d47af7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 3475 additions and 8256 deletions

4
.gitignore vendored
View file

@ -42,5 +42,7 @@ infra/cdk/node_modules
infra/cdk/cdk.out
infra/cdk/cdk.context.json
infra/cdk/*.d.ts
infra/cdk/bin/*.d.ts
infra/cdk/bin/*.js
infra/cdk/lib/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js

View file

@ -4,8 +4,8 @@ Vite + React SPA for SeaHaven facility management (work orders, vendor portal, u
## Requirements
- Node.js 20+
- npm 10+
- Node.js 22.22.1+
- npm 11.16.0
## Setup

View file

@ -20,7 +20,18 @@ const legacyIgnores = [
export default tseslint.config(
{
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
ignores: [
"dist/**",
"build/**",
"node_modules/**",
"coverage/**",
"infra/cdk/cdk.out/**",
"infra/cdk/bin/**/*.d.ts",
"infra/cdk/bin/**/*.js",
"infra/cdk/lib/**/*.d.ts",
"infra/cdk/lib/**/*.js",
...legacyIgnores,
],
},
js.configs.recommended,
...tseslint.configs.recommended,

View file

@ -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.
- **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`.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -1,134 +1,221 @@
{
"name": "shoc-frontend-infra",
"version": "0.1.0",
"lockfileVersion": 1,
"lockfileVersion": 3,
"requires": true,
"dependencies": {
"@aws-cdk/asset-awscli-v1": {
"version": "2.2.282",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g=="
},
"@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q=="
},
"@aws-cdk/cloud-assembly-schema": {
"version": "54.5.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz",
"integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==",
"requires": {
"jsonschema": "^1.5.0",
"semver": "^7.8.4"
},
"packages": {
"": {
"name": "shoc-frontend-infra",
"version": "0.1.0",
"dependencies": {
"jsonschema": {
"version": "1.5.0",
"bundled": true
},
"semver": {
"version": "7.8.4",
"bundled": true
}
"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"
}
},
"@cspotcode/source-map-support": {
"node_modules/@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"
},
"node_modules/@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"
},
"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": {
"jsonschema": "^1.5.0",
"semver": "^7.8.5"
},
"engines": {
"node": ">= 18.0.0"
}
},
"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": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"requires": {
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"@jridgewell/resolve-uri": {
"node_modules/@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
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"@jridgewell/sourcemap-codec": {
"node_modules/@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
"dev": true,
"license": "MIT"
},
"@jridgewell/trace-mapping": {
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"requires": {
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@tsconfig/node10": {
"node_modules/@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
"dev": true,
"license": "MIT"
},
"@tsconfig/node12": {
"node_modules/@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
"dev": true,
"license": "MIT"
},
"@tsconfig/node14": {
"node_modules/@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
"dev": true,
"license": "MIT"
},
"@tsconfig/node16": {
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"dev": true,
"requires": {
"undici-types": "~6.21.0"
"license": "MIT"
},
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"acorn": {
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"acorn-walk": {
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"requires": {
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"arg": {
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
"dev": true,
"license": "MIT"
},
"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": {
"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-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": {
"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/asset-awscli-v1": "2.2.282",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
"@aws-cdk/cloud-assembly-api": "^2.2.5",
@ -142,231 +229,212 @@
"minimatch": "^10.2.5",
"punycode": "^2.3.1",
"semver": "^7.8.1",
"table": "^6.9.0",
"yaml": "1.10.3"
},
"dependencies": {
"@aws-cdk/cloud-assembly-api": {
"version": "2.2.5",
"bundled": true,
"requires": {
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
}
},
"@balena/dockerignore": {
"version": "1.0.2",
"bundled": true
},
"ajv": {
"version": "8.20.0",
"bundled": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}
},
"ansi-regex": {
"version": "5.0.1",
"bundled": true
},
"ansi-styles": {
"version": "4.3.0",
"bundled": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"astral-regex": {
"version": "2.0.0",
"bundled": true
},
"balanced-match": {
"version": "4.0.4",
"bundled": true
},
"brace-expansion": {
"version": "5.0.6",
"bundled": true,
"requires": {
"balanced-match": "^4.0.2"
}
},
"case": {
"version": "1.6.3",
"bundled": true
},
"color-convert": {
"version": "2.0.1",
"bundled": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"bundled": true
},
"emoji-regex": {
"version": "8.0.0",
"bundled": true
},
"fast-deep-equal": {
"version": "3.1.3",
"bundled": true
},
"fast-uri": {
"version": "3.1.2",
"bundled": true
},
"fs-extra": {
"version": "11.3.5",
"bundled": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"graceful-fs": {
"version": "4.2.11",
"bundled": true
},
"ignore": {
"version": "5.3.2",
"bundled": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"bundled": true
},
"json-schema-traverse": {
"version": "1.0.0",
"bundled": true
},
"jsonfile": {
"version": "6.2.1",
"bundled": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"jsonschema": {
"version": "1.5.0",
"bundled": true
},
"lodash.truncate": {
"version": "4.4.2",
"bundled": true
},
"mime-db": {
"version": "1.52.0",
"bundled": true
},
"mime-types": {
"version": "2.1.35",
"bundled": true,
"requires": {
"mime-db": "1.52.0"
}
},
"minimatch": {
"version": "10.2.5",
"bundled": true,
"requires": {
"brace-expansion": "^5.0.5"
}
},
"punycode": {
"version": "2.3.1",
"bundled": true
},
"require-from-string": {
"version": "2.0.2",
"bundled": true
},
"semver": {
"version": "7.8.1",
"bundled": true
},
"slice-ansi": {
"version": "4.0.0",
"bundled": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
}
},
"string-width": {
"version": "4.2.3",
"bundled": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"bundled": true,
"requires": {
"ansi-regex": "^5.0.1"
}
},
"table": {
"version": "6.9.0",
"bundled": true,
"requires": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
}
},
"universalify": {
"version": "2.0.1",
"bundled": true
},
"yaml": {
"version": "1.10.3",
"bundled": true
}
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"constructs": "^10.5.0"
}
},
"constructs": {
"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"
}
},
"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": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ=="
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
"license": "Apache-2.0"
},
"create-require": {
"node_modules/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
"dev": true,
"license": "MIT"
},
"diff": {
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"make-error": {
"node_modules/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
"dev": true,
"license": "ISC"
},
"ts-node": {
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"requires": {
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
@ -380,31 +448,67 @@
"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
}
}
},
"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/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"
}
},
"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/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"
},
"v8-compile-cache-lib": {
"node_modules/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
"dev": true,
"license": "MIT"
},
"yn": {
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

View file

@ -6,6 +6,9 @@
"bin": {
"app": "bin/app.ts"
},
"engines": {
"node": ">=22.22.1"
},
"scripts": {
"build": "tsc",
"synth": "cdk synth",
@ -13,13 +16,14 @@
"deploy": "cdk deploy"
},
"devDependencies": {
"@types/node": "^22.15.30",
"aws-cdk": "^2.1021.0",
"@types/node": "^24.13.3",
"aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2",
"typescript": "~5.8.3"
"typescript": "~6.0.3"
},
"dependencies": {
"aws-cdk-lib": "^2.220.0",
"aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2"
}
},
"packageManager": "npm@11.16.0"
}

9235
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -44,61 +44,60 @@
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/montserrat": "^5.2.8",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"clsx": "^2.1.1",
"ky": "^1.7.5",
"lucide-react": "^1.17.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.78.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5",
"ky": "^2.0.2",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-icons": "^5.7.0",
"react-router": "^8.2.0",
"react-toastify": "^11.1.0",
"tailwind-merge": "^3.6.0",
"web-vitals": "^2.1.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.15.30",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^10.4.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.4.0",
"prettier": "^3.8.4",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"typescript-eslint": "^8.61.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"prettier": "^3.9.5",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20"
"node": ">=22.22.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "npm@11.16.0"
}

View file

@ -8,7 +8,8 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:3000",
// Port 4173 isolates Playwright from the app's normal port 3000 server.
baseURL: "http://localhost:4173",
trace: "on-first-retry",
},
projects: [
@ -18,8 +19,8 @@ export default defineConfig({
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
command: "npm run dev -- --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},

View file

@ -1,12 +1,4 @@
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
if (status === 401) {
@ -33,13 +25,21 @@ export function mapHttpStatusToMessage(status: number, data?: unknown): string {
return "An error occurred";
}
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
export function normalizeApiRequestError(error: Error): Error {
if (isHTTPError(error)) {
error.message = mapHttpStatusToMessage(error.response.status, error.data);
return error;
}
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
if (isNetworkError(error)) {
error.message = "No response from server. Please check your connection.";
return error;
}
if (isTimeoutError(error)) {
error.message = "The request timed out. Please try again.";
return error;
}
return error;
}

View file

@ -1,8 +1,8 @@
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
import ky, { type KyInstance, type Options } from "ky";
import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
@ -21,36 +21,23 @@ function isAuthLoginRequest(request: Request): boolean {
}
export const api: KyInstance = ky.create({
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
prefix: resolveApiPrefixUrl(env.apiUrl),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
(request) => {
({ request }) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
},
],
beforeError: [
async (error: HTTPError) => {
const { response } = error;
if (!response) {
error.message = "No response from server. Please check your connection.";
return error;
}
const apiError = await createApiErrorFromResponse(response);
error.message = apiError.message;
return error;
},
],
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
async (request, _options, response) => {
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}

View file

@ -1,4 +1,4 @@
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
import { GuestRoute } from "@/components/auth/guest-route";

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -95,7 +95,13 @@ export default function AccountFormPage() {
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Account" : "Create an Account"}
</Typography>
@ -166,7 +172,13 @@ export default function AccountFormPage() {
/>
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={2}>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/accounts")} disabled={isSaving}>
Cancel
</Button>

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -70,20 +70,39 @@ export default function AccountsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Accounts
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -99,13 +118,11 @@ export default function AccountsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -132,7 +149,13 @@ export default function AccountsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No accounts found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No accounts found
</Typography>
</TableCell>
</TableRow>
) : (
@ -180,7 +203,6 @@ export default function AccountsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -193,7 +215,6 @@ export default function AccountsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Account</DialogTitle>
<DialogContent>

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -195,7 +195,13 @@ export default function AssetFormPage() {
)}
/>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
Cancel
</Button>

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -68,20 +68,39 @@ export default function AssetsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Assets
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -97,13 +116,11 @@ export default function AssetsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -129,7 +146,13 @@ export default function AssetsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography color="text.secondary">No assets found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No assets found
</Typography>
</TableCell>
</TableRow>
) : (
@ -182,7 +205,6 @@ export default function AssetsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -195,7 +217,6 @@ export default function AssetsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Asset</DialogTitle>
<DialogContent>

View file

@ -6,7 +6,12 @@ export function CalendarUnavailablePanel() {
<Alert severity="warning" className="mb-4">
Calendário indisponível — a API de calendário ainda não está implementada no backend.
</Alert>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
As rotas permanecem reservadas para quando o CalendarController for disponibilizado.
</Typography>
</Box>

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router";
import {
Alert,
Box,
@ -128,10 +128,12 @@ export default function EventFormPage() {
label="Title"
required
fullWidth
inputProps={{ maxLength: 100 }}
error={Boolean(errors.title)}
helperText={errors.title?.message}
{...register("title")}
slotProps={{
htmlInput: { maxLength: 100 },
}}
/>
<TextField
@ -145,8 +147,10 @@ export default function EventFormPage() {
<TextField
label="Location"
fullWidth
inputProps={{ maxLength: 150 }}
{...register("location")}
slotProps={{
htmlInput: { maxLength: 150 },
}}
/>
<Controller
@ -193,7 +197,7 @@ export default function EventFormPage() {
label="Start Date"
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
error={Boolean(errors.startDate)}
helperText={errors.startDate?.message}
{...register("startDate")}
@ -223,7 +227,7 @@ export default function EventFormPage() {
label="End Date"
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
error={Boolean(errors.endDate)}
helperText={errors.endDate?.message}
{...register("endDate")}
@ -258,7 +262,13 @@ export default function EventFormPage() {
)}
/>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/calendar")}

View file

@ -1,4 +1,4 @@
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
import { Alert, Box } from "@mui/material";
export default function CalendarLayout() {

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -238,7 +238,13 @@ export default function ContactFormPage() {
<TextField label="Website" fullWidth placeholder="http://" {...register("website")} />
<TextField label="SMS Address" fullWidth {...register("smsAddress")} />
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/contacts")}

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -55,20 +55,39 @@ export default function ContactsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Contacts
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -84,13 +103,11 @@ export default function ContactsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contacts"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -116,7 +133,13 @@ export default function ContactsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography color="text.secondary">No contacts found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No contacts found
</Typography>
</TableCell>
</TableRow>
) : (
@ -155,7 +178,6 @@ export default function ContactsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -168,7 +190,6 @@ export default function ContactsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Contact</DialogTitle>
<DialogContent>

View file

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
@ -71,7 +71,12 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
</Typography>
)}
{description && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
@ -89,20 +94,31 @@ export default function DashboardPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
@ -135,13 +151,17 @@ export default function DashboardPage() {
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
@ -183,7 +203,6 @@ export default function DashboardPage() {
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -178,7 +178,13 @@ export default function EmployeeFormPage() {
<Box className="mx-auto max-w-5xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit an Employee" : "Create an Employee"}
</Typography>
@ -244,7 +250,7 @@ export default function EmployeeFormPage() {
{...register("dateOfBirth")}
label="Date of Birth"
type="date"
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<JobTitleSelect control={control} setValue={setValue} />
@ -255,7 +261,7 @@ export default function EmployeeFormPage() {
{...register("hireDate")}
label="Hire Date"
type="date"
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<SelectField
@ -271,7 +277,12 @@ export default function EmployeeFormPage() {
<Typography variant="subtitle1" className="font-semibold">
Login &amp; Role
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{isEdit
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in."
: "A confirmation email will be sent to activate the Employee's account."}
@ -345,7 +356,13 @@ export default function EmployeeFormPage() {
<TextField {...register("twitterUrl")} label="Twitter" fullWidth />
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={2}>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/employees")} disabled={isSaving}>
Cancel
</Button>

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -76,20 +76,39 @@ export default function EmployeesListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Employees
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -105,13 +124,11 @@ export default function EmployeesListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employees"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -137,7 +154,13 @@ export default function EmployeesListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography color="text.secondary">No employees found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No employees found
</Typography>
</TableCell>
</TableRow>
) : (
@ -191,7 +214,6 @@ export default function EmployeesListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -204,7 +226,6 @@ export default function EmployeesListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Employee</DialogTitle>
<DialogContent>

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -218,7 +218,13 @@ export default function FollowUpFormPage() {
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} />
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
onClick={() => navigate("/followups")}

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -64,20 +64,39 @@ export default function FollowUpsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Follow-ups
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -110,13 +129,11 @@ export default function FollowUpsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -143,7 +160,13 @@ export default function FollowUpsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No follow-ups found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No follow-ups found
</Typography>
</TableCell>
</TableRow>
) : (
@ -191,7 +214,6 @@ export default function FollowUpsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -204,7 +226,6 @@ export default function FollowUpsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Follow-up</DialogTitle>
<DialogContent>

View file

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
@ -71,7 +71,12 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
</Typography>
)}
{description && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
@ -89,20 +94,31 @@ export default function DashboardPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
@ -135,13 +151,17 @@ export default function DashboardPage() {
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
@ -183,7 +203,6 @@ export default function DashboardPage() {
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Autocomplete,
@ -99,7 +99,13 @@ export default function LocationFormPage() {
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
@ -251,7 +257,13 @@ export default function LocationFormPage() {
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={2}>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -70,20 +70,39 @@ export default function LocationsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Locations
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -99,13 +118,11 @@ export default function LocationsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -132,7 +149,13 @@ export default function LocationsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No locations found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No locations found
</Typography>
</TableCell>
</TableRow>
) : (
@ -180,7 +203,6 @@ export default function LocationsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -193,7 +215,6 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -122,7 +122,7 @@ export default function PmScheduleFormPage() {
type="date"
required
fullWidth
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
error={Boolean(errors.startDate)}
helperText={errors.startDate?.message}
{...register("startDate")}
@ -193,7 +193,13 @@ export default function PmScheduleFormPage() {
)}
/>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
Cancel
</Button>

View file

@ -1,5 +1,5 @@
import { Alert, Box, Button, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
export function PmScheduleEditUnavailablePanel() {
return (
@ -8,7 +8,12 @@ export function PmScheduleEditUnavailablePanel() {
PM Schedule editing is unavailable — the production API does not expose{" "}
<code className="text-sm">PMSchedule/GetById</code>.
</Alert>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
List and create remain available via <code className="text-sm">GetList</code> and{" "}
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
GetById.

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -72,20 +72,39 @@ export default function PmSchedulesListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
PM Schedules
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -103,13 +122,11 @@ export default function PmSchedulesListPage() {
)}
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load PM schedules"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -137,7 +154,13 @@ export default function PmSchedulesListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Typography color="text.secondary">No PM schedules found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No PM schedules found
</Typography>
</TableCell>
</TableRow>
) : (
@ -203,7 +226,6 @@ export default function PmSchedulesListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -216,7 +238,6 @@ export default function PmSchedulesListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
{pmScheduleCapabilities.canDelete && (
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete PM Schedule</DialogTitle>

View file

@ -120,16 +120,19 @@ export default function DropdownOptionsPage() {
return (
<Box className="flex flex-col gap-4 p-4">
<SettingsNav />
<Stack spacing={0.5}>
<Typography variant="h5" component="h1">
Dropdown Options
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Manage configurable dropdown values for work orders
</Typography>
</Stack>
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
@ -148,7 +151,13 @@ export default function DropdownOptionsPage() {
</Stack>
<Box className="flex flex-1 flex-col gap-4 p-4">
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "stretch",
}}
>
{parentCategory && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
@ -199,11 +208,22 @@ export default function DropdownOptionsPage() {
)}
{isLoading ? (
<Stack alignItems="center" className="py-10">
<Stack
className="py-10"
sx={{
alignItems: "center",
}}
>
<CircularProgress size={28} />
</Stack>
) : options.length === 0 ? (
<Typography align="center" color="text.secondary" className="py-10">
<Typography
align="center"
className="py-10"
sx={{
color: "text.secondary",
}}
>
No options configured yet
</Typography>
) : (
@ -231,7 +251,14 @@ export default function DropdownOptionsPage() {
className="flex-1"
/>
) : (
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
<Stack
direction="row"
spacing={1}
className="min-w-0"
sx={{
alignItems: "center",
}}
>
<Typography variant="body2" className="truncate">
{item.value}
</Typography>

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import {
Alert,
Box,
@ -115,20 +115,22 @@ export default function TaskTemplatesPage() {
return (
<Box className="p-4">
<SettingsNav />
<Stack spacing={1} className="mb-4">
<Typography variant="h5">Task List Templates</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Create reusable checklists for work order dispatches
</Typography>
</Stack>
{error && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
</Alert>
)}
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
<Paper variant="outlined" className="p-3">
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
@ -158,7 +160,12 @@ export default function TaskTemplatesPage() {
<Paper variant="outlined" className="p-4">
{!selectedId ? (
<Typography color="text.secondary" className="py-10 text-center">
<Typography
className="py-10 text-center"
sx={{
color: "text.secondary",
}}
>
Select a template or create a new one
</Typography>
) : (
@ -236,7 +243,13 @@ export default function TaskTemplatesPage() {
</Button>
</Stack>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
{!isNew && (
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
Delete
@ -254,7 +267,6 @@ export default function TaskTemplatesPage() {
)}
</Paper>
</Box>
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
<DialogTitle>Delete Template</DialogTitle>
<DialogContent>

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import {
Alert,
Box,
@ -96,14 +96,27 @@ export default function UpliftQueuePage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
@ -144,13 +157,11 @@ export default function UpliftQueuePage() {
</FormControl>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -177,7 +188,11 @@ export default function UpliftQueuePage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography color="text.secondary">
<Typography
sx={{
color: "text.secondary",
}}
>
No uplift requests match these filters.
</Typography>
</TableCell>
@ -257,7 +272,6 @@ export default function UpliftQueuePage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -266,7 +280,6 @@ export default function UpliftQueuePage() {
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve"

View file

@ -1,5 +1,5 @@
import { useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -33,7 +33,13 @@ function formatMoney(value: number | null | undefined): string {
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
<Typography variant="body2">
@ -107,7 +113,14 @@ export default function VendorPoViewPage() {
return (
<Box className="mx-auto max-w-5xl p-4">
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
<Stack
direction="row"
className="mb-4"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Button variant="text" onClick={() => navigate(-1)}>
← Back
</Button>
@ -115,27 +128,47 @@ export default function VendorPoViewPage() {
Print
</Button>
</Stack>
<Stack spacing={1} className="mb-4">
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="body2" color="text.secondary">
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
}}
>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
· {vendor.companyName}
</Typography>
)}
{dispatch.nteAmount != null && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
· NTE {formatMoney(dispatch.nteAmount)}
</Typography>
)}
</Stack>
</Stack>
<Stack spacing={3}>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
@ -153,7 +186,13 @@ export default function VendorPoViewPage() {
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No vendor attached.
</Typography>
)}
</Paper>
@ -175,7 +214,12 @@ export default function VendorPoViewPage() {
</Box>
{dispatch.description && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
@ -211,7 +255,13 @@ export default function VendorPoViewPage() {
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders linked.
</Typography>
) : (
<Table size="small">
<TableHead>
@ -251,19 +301,33 @@ export default function VendorPoViewPage() {
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No uplift requests on this PO.
</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
sx={{
color: "text.secondary",
}}
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
@ -307,7 +371,6 @@ export default function VendorPoViewPage() {
)}
</Paper>
</Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import {
Alert,
Box,
@ -92,14 +92,27 @@ export default function VendorPosListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Vendor POs
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
@ -116,8 +129,13 @@ export default function VendorPosListPage() {
className="min-w-[280px]"
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} flexWrap="wrap">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
flexWrap: "wrap",
}}
>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
@ -181,13 +199,11 @@ export default function VendorPosListPage() {
Clear filters
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -213,7 +229,13 @@ export default function VendorPosListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography color="text.secondary">No vendor POs found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No vendor POs found
</Typography>
</TableCell>
</TableRow>
) : (
@ -263,7 +285,6 @@ export default function VendorPosListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}

View file

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -188,7 +188,13 @@ export default function VendorFormPage() {
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
Cancel
</Button>

View file

@ -47,7 +47,13 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
return (
<Paper variant="outlined" className="p-4">
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary" className="mt-2">
<Typography
variant="body2"
className="mt-2"
sx={{
color: "text.secondary",
}}
>
Loading portal link…
</Typography>
</Paper>
@ -64,7 +70,13 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
<Typography variant="subtitle1" className="font-semibold">
Vendor Portal
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
<Typography
variant="body2"
className="mt-1 mb-3"
sx={{
color: "text.secondary",
}}
>
No active portal link. Generate one so this vendor can access the portal.
</Typography>
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
@ -79,7 +91,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor Portal
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
<TextField
fullWidth
@ -92,15 +103,20 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
Copy
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
<Typography
variant="caption"
className="mb-3"
sx={{
color: "text.secondary",
display: "block",
}}
>
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
{tokenInfo.lastUsedAt
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
: " · Never used"}
</Typography>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
@ -119,7 +135,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
Revoke
</Button>
</Stack>
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Rotate Portal Token</DialogTitle>
<DialogContent>
@ -141,7 +156,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
<DialogTitle>Revoke Portal Tokens</DialogTitle>
<DialogContent>

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
@ -61,20 +61,39 @@ export default function VendorsListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Vendors
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -90,13 +109,11 @@ export default function VendorsListPage() {
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendors"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -122,7 +139,13 @@ export default function VendorsListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<Typography color="text.secondary">No vendors found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
</TableCell>
</TableRow>
) : (
@ -175,7 +198,6 @@ export default function VendorsListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
@ -188,7 +210,6 @@ export default function VendorsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Vendor</DialogTitle>
<DialogContent>

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import {
Accordion,
AccordionDetails,
@ -63,18 +63,37 @@ function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
);
if (!sorted.length) {
return <Typography color="text.secondary">No comments yet.</Typography>;
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No comments yet.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" className="mb-1">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
@ -95,18 +114,37 @@ function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
);
if (!sorted.length) {
return <Typography color="text.secondary">No changes recorded.</Typography>;
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No changes recorded.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" className="mb-1">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
@ -183,14 +221,21 @@ export default function WorkOrderViewPage() {
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography color="text.primary">{wo.internalWONumber}</Typography>
<Typography
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "flex-start" }}
sx={{
justifyContent: "space-between",
alignItems: { xs: "stretch", md: "flex-start" },
}}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
@ -224,7 +269,13 @@ export default function WorkOrderViewPage() {
</Menu>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
Dispatch
</Button>
@ -236,7 +287,6 @@ export default function WorkOrderViewPage() {
</Button>
</Stack>
</Stack>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
@ -247,7 +297,13 @@ export default function WorkOrderViewPage() {
<Field label="Priority" value={wo.priority || "—"} />
<Field label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
Assigned To
</Typography>
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
@ -284,10 +340,15 @@ export default function WorkOrderViewPage() {
<Field label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Classification</Typography>
<Typography
sx={{
fontWeight: 600,
}}
>
Classification
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -297,10 +358,15 @@ export default function WorkOrderViewPage() {
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Schedule</Typography>
<Typography
sx={{
fontWeight: 600,
}}
>
Schedule
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
@ -314,10 +380,15 @@ export default function WorkOrderViewPage() {
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Financial</Typography>
<Typography
sx={{
fontWeight: 600,
}}
>
Financial
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -330,10 +401,15 @@ export default function WorkOrderViewPage() {
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
<Typography
sx={{
fontWeight: 600,
}}
>
Dispatches ({wo.dispatches.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
@ -393,20 +469,30 @@ export default function WorkOrderViewPage() {
</Table>
</TableContainer>
) : (
<Typography color="text.secondary">No dispatches yet.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No dispatches yet.
</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Description</Typography>
<Typography
sx={{
fontWeight: 600,
}}
>
Description
</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
@ -446,7 +532,6 @@ export default function WorkOrderViewPage() {
</Box>
)}
</Paper>
{dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
@ -454,7 +539,6 @@ export default function WorkOrderViewPage() {
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
@ -470,7 +554,13 @@ export default function WorkOrderViewPage() {
function Field({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>

View file

@ -138,13 +138,18 @@ export function DispatchCreateModal({
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
@ -203,9 +208,21 @@ export function DispatchCreateModal({
className="mb-2"
/>
{isLoadingVendors ? (
<Typography color="text.secondary">Loading vendors...</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
Loading vendors...
</Typography>
) : filteredVendors.length === 0 ? (
<Typography color="text.secondary">No vendors found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{filteredVendors.map((vendor) => (
@ -226,12 +243,24 @@ export function DispatchCreateModal({
{vendor.companyName}
</Typography>
{vendor.tradeSpecialties && (
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.tradeSpecialties}
</Typography>
)}
{vendor.address && (
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.address}
</Typography>
)}
@ -253,8 +282,10 @@ export function DispatchCreateModal({
type="number"
value={nteAmount}
onChange={(event) => setNteAmount(event.target.value)}
inputProps={{ step: "0.01", min: 0 }}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
@ -282,7 +313,13 @@ export function DispatchCreateModal({
</TextField>
{customItems.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1}>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
@ -317,7 +354,6 @@ export function DispatchCreateModal({
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
Cancel

View file

@ -207,7 +207,12 @@ export function DispatchDetailModal({
<Typography variant="h6">
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
PO: {dispatch.poNumber} ·{" "}
{dispatch.workOrders.length > 0
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
@ -247,7 +252,9 @@ export function DispatchDetailModal({
onChange={(event) =>
setForm((current) => ({ ...current, nteAmount: event.target.value }))
}
inputProps={{ step: "0.01" }}
slotProps={{
htmlInput: { step: "0.01" },
}}
/>
<TextField
label="Scheduled Date"
@ -372,7 +379,14 @@ export function DispatchDetailModal({
</Typography>
<Stack spacing={1}>
{dispatch.checklistItems.map((item) => (
<Stack key={String(item.id)} direction="row" spacing={1} alignItems="center">
<Stack
key={String(item.id)}
direction="row"
spacing={1}
sx={{
alignItems: "center",
}}
>
<Checkbox
checked={item.isCompleted}
disabled={isLocked || updateChecklistMutation.isPending}
@ -387,7 +401,12 @@ export function DispatchDetailModal({
<Box className="flex-1">
<Typography variant="body2">{item.itemText}</Typography>
{item.isCompleted && (
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{item.completedBy}
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
</Typography>
@ -441,7 +460,12 @@ export function DispatchDetailModal({
{signoff.signature}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{signoff.name}
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
</Typography>
@ -471,7 +495,13 @@ export function DispatchDetailModal({
NTE Uplift Requests
</Typography>
{uplifts.length === 0 ? (
<Typography color="text.secondary">No uplift requests.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No uplift requests.
</Typography>
) : (
<Stack spacing={2}>
{uplifts.map((uplift) => {
@ -479,7 +509,14 @@ export function DispatchDetailModal({
return (
<Box key={String(uplift.id)} className="rounded border border-border p-3">
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
}}
>
<Chip label={uplift.status} size="small" />
{uplift.requiredTier === 2 && (
<Typography variant="caption">Tier 2 · Manager approval</Typography>
@ -496,7 +533,13 @@ export function DispatchDetailModal({
</Typography>
)}
{(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography variant="body2" color="text.secondary" className="mt-1">
<Typography
variant="body2"
className="mt-1"
sx={{
color: "text.secondary",
}}
>
Requested by {uplift.requestedByVendorName || "vendor"}
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
</Typography>
@ -505,7 +548,12 @@ export function DispatchDetailModal({
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
<Box className="mt-1">
{(uplift.decidedByName || uplift.decidedAt) && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{uplift.status} by {uplift.decidedByName || "—"}
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography>
@ -564,16 +612,32 @@ export function DispatchDetailModal({
</Typography>
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
{dispatch.comments.length === 0 ? (
<Typography color="text.secondary">No messages yet.</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No messages yet.
</Typography>
) : (
<Stack spacing={2}>
{dispatch.comments.map((comment) => (
<Box key={String(comment.id)}>
<Stack direction="row" justifyContent="space-between">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
@ -595,9 +659,11 @@ export function DispatchDetailModal({
/>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
className="mt-2"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<FormControlLabel
control={
@ -625,9 +691,20 @@ export function DispatchDetailModal({
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
</Alert>
) : (
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
{verifyState.missing.length > 0 && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Missing: {verifyState.missing.join(", ")}
</Typography>
)}
@ -643,7 +720,6 @@ export function DispatchDetailModal({
)}
</DialogContent>
</Dialog>
{sigCaptureType && (
<SignatureCapture
title={`${sigCaptureType} Sign-off`}
@ -687,7 +763,13 @@ function DetailField({
return (
<Box className={className}>
<Typography variant="caption" color="text.secondary" display="block">
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
<Typography variant="body2" className={colorClass}>

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import {
Alert,
Autocomplete,
@ -131,13 +131,17 @@ export default function WorkOrderFormPage() {
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
<Typography
sx={{
color: "text.primary",
}}
>
{isEdit ? "Edit" : "Create"}
</Typography>
</Breadcrumbs>
<Typography variant="h5" component="h1">
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
</Typography>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box>
@ -315,8 +319,10 @@ export default function WorkOrderFormPage() {
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
inputProps={{ step: "0.01", min: 0 }}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
@ -340,7 +346,13 @@ export default function WorkOrderFormPage() {
/>
</Box>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
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";
@ -120,20 +120,39 @@ export default function WorkOrdersListPage() {
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Work Orders
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
@ -149,8 +168,15 @@ export default function WorkOrdersListPage() {
</Button>
</Stack>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "center",
}}
>
<Chip
label="All"
clickable
@ -193,13 +219,11 @@ export default function WorkOrdersListPage() {
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
@ -237,7 +261,13 @@ export default function WorkOrdersListPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography color="text.secondary">No work orders found</Typography>
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders found
</Typography>
</TableCell>
</TableRow>
) : (
@ -292,7 +322,6 @@ export default function WorkOrdersListPage() {
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}

View file

@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { Box, Button, Typography } from "@mui/material";
export default function NotFoundPage() {

View file

@ -1,4 +1,4 @@
import { NavLink, Outlet, useLocation, useParams } from "react-router-dom";
import { NavLink, Outlet, useLocation, useParams } from "react-router";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
import "@/app/v/_components/vendor-portal.css";

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";

View file

@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";

View file

@ -1,4 +1,4 @@
import { Navigate } from "react-router-dom";
import { Navigate } from "react-router";
export default function VendorPortalIndex() {
return <Navigate to="dashboard" replace />;

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useParams } from "react-router-dom";
import { useParams } from "react-router";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import {

View file

@ -1,4 +1,4 @@
import { Navigate } from "react-router-dom";
import { Navigate } from "react-router";
import { useAuthContext } from "@/providers/auth-context";
import { Box, CircularProgress } from "@mui/material";

View file

@ -1,4 +1,4 @@
import { Navigate } from "react-router-dom";
import { Navigate } from "react-router";
import { Box, CircularProgress } from "@mui/material";
import { useAuthContext } from "@/providers/auth-context";

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@ -33,10 +33,19 @@ export function CalendarActionBar({
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
alignItems={{ xs: "stretch", lg: "center" }}
justifyContent="space-between"
sx={{
alignItems: { xs: "stretch", lg: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
alignItems: "center",
}}
>
<Button
startIcon={<InboxIcon />}
onClick={(event) => setWorkspaceAnchor(event.currentTarget)}
@ -99,7 +108,14 @@ export function CalendarActionBar({
</Button>
</Stack>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
}}
>
<Typography variant="h6" className="min-w-[180px]">
{title}
</Typography>

View file

@ -1,4 +1,4 @@
import { NavLink } from "react-router-dom";
import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { NavLink } from "react-router";
import { Box, Drawer } from "@mui/material";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
@ -169,7 +169,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
/>
</Box>
</Toolbar>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
@ -199,10 +198,20 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
{userInitials}
</Box>
<Box>
<Typography variant="body2" fontWeight={600}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{user?.email || ""}
</Typography>
</Box>

View file

@ -24,7 +24,13 @@ export function EmptyState({ icon, title, description, action, className }: Empt
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" className="max-w-sm">
<Typography
variant="body2"
className="max-w-sm"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}

View file

@ -1,5 +1,5 @@
import type { ComponentPropsWithoutRef } from "react";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { Typography } from "@mui/material";
import { cn } from "@/lib/utils";

View file

@ -16,9 +16,11 @@ export function PageHeader({ eyebrow, title, subtitle, actions, className }: Pag
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
className={cn("mb-4", className)}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack spacing={0.5}>
{eyebrow && (
@ -30,13 +32,24 @@ export function PageHeader({ eyebrow, title, subtitle, actions, className }: Pag
{title}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{subtitle}
</Typography>
)}
</Stack>
{actions && (
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
}}
>
{actions}
</Stack>
)}

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { accountsApi } from "@/domain/accounts/api/accounts-api";
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { accountsApi } from "@/domain/accounts/api/accounts-api";
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { assetsApi } from "@/domain/assets/api/assets-api";
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { assetsApi } from "@/domain/assets/api/assets-api";
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema";

View file

@ -5,7 +5,7 @@ import {
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { contactsApi } from "@/domain/contacts/api/contacts-api";
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { contactsApi } from "@/domain/contacts/api/contacts-api";
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { employeesApi } from "@/domain/employees/api/employees-api";
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { employeesApi } from "@/domain/employees/api/employees-api";
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";

View file

@ -9,7 +9,7 @@ function getDefaultOrigin(): string {
}
/**
* Resolves VITE_API_URL to a Ky-compatible prefixUrl (always ends with /).
* Resolves VITE_API_URL to a Ky-compatible prefix (always ends with /).
*
* Contract:
* - Relative paths (e.g. `/api`) are joined with the app origin.

View file

@ -168,11 +168,16 @@ const themeOptions: ThemeOptions = {
outline: `2px solid ${ring}`,
outlineOffset: 2,
},
},
containedPrimary: {
"&:hover": {
backgroundColor: primaryHover,
},
variants: [
{
props: { variant: "contained", color: "primary" },
style: {
"&:hover": {
backgroundColor: primaryHover,
},
},
},
],
},
outlined: {
borderColor: border,
@ -278,22 +283,36 @@ const themeOptions: ThemeOptions = {
root: {
borderRadius: v("--radius-md", "6px"),
fontSize: "0.875rem",
},
standardSuccess: {
backgroundColor: v("--status-completed-bg", "#f0fdf4"),
color: v("--status-completed-text", "#166534"),
},
standardError: {
backgroundColor: v("--doc-no-bg", "#fff1f2"),
color: v("--doc-no-text", "#be123c"),
},
standardWarning: {
backgroundColor: v("--status-pending-bg", "#fff7ed"),
color: v("--status-pending-text", "#92400e"),
},
standardInfo: {
backgroundColor: v("--color-primary-tint", "#e6f4fb"),
color: v("--sidebar-accent-foreground", "#155a92"),
variants: [
{
props: { variant: "standard", severity: "success" },
style: {
backgroundColor: v("--status-completed-bg", "#f0fdf4"),
color: v("--status-completed-text", "#166534"),
},
},
{
props: { variant: "standard", severity: "error" },
style: {
backgroundColor: v("--doc-no-bg", "#fff1f2"),
color: v("--doc-no-text", "#be123c"),
},
},
{
props: { variant: "standard", severity: "warning" },
style: {
backgroundColor: v("--status-pending-bg", "#fff7ed"),
color: v("--status-pending-text", "#92400e"),
},
},
{
props: { variant: "standard", severity: "info" },
style: {
backgroundColor: v("--color-primary-tint", "#e6f4fb"),
color: v("--sidebar-accent-foreground", "#155a92"),
},
},
],
},
},
},

View file

@ -1,4 +1,5 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import { routes } from "@/routing/build-route-tree";

View file

@ -0,0 +1,54 @@
import ky from "ky";
import { describe, expect, it, vi } from "vitest";
import { normalizeApiRequestError } from "@/api/api-error";
const RESOURCE_URL = "https://example.test/api/resource";
function createClient(fetchImpl: typeof fetch) {
return ky.create({
fetch: fetchImpl,
retry: 0,
timeout: 50,
hooks: { beforeError: [({ error }) => normalizeApiRequestError(error)] },
});
}
describe("normalizeApiRequestError", () => {
it("surfaces Ky's pre-parsed JSON body for HTTP errors via the hook", async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ message: "Validation failed" }), {
status: 422,
headers: { "Content-Type": "application/json" },
}),
),
) as unknown as typeof fetch;
await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow(
"Validation failed",
);
});
it("reports a connection failure for network errors", async () => {
const fetchImpl = vi.fn(() =>
Promise.reject(new TypeError("fetch failed")),
) as unknown as typeof fetch;
await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow(
"No response from server. Please check your connection.",
);
});
it("returns a friendly timeout message", async () => {
const fetchImpl = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal;
return new Promise<Response>((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(signal?.reason));
});
}) as unknown as typeof fetch;
await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow(
"The request timed out. Please try again.",
);
});
});

View file

@ -1,6 +1,6 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/(auth)/login";
@ -53,6 +53,7 @@ describe("LoginPage integration", () => {
});
it("keeps submit disabled until email and password are valid", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginRoutes />, { route: "/login" });
await waitFor(() => {
@ -68,16 +69,17 @@ describe("LoginPage integration", () => {
expect(submitButton).toBeDisabled();
await userEvent.type(emailInput, "not-an-email");
await userEvent.type(passwordInput, "secret123");
await user.type(emailInput, "not-an-email");
await user.type(passwordInput, "secret123");
expect(submitButton).toBeDisabled();
await userEvent.clear(emailInput);
await userEvent.type(emailInput, "user@seahavenind.com");
await user.clear(emailInput);
await user.type(emailInput, "user@seahavenind.com");
expect(submitButton).toBeEnabled();
});
it("logs in successfully and navigates to dashboard", async () => {
const user = userEvent.setup();
vi.mocked(authApi.login).mockResolvedValue(TEST_USER);
renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -88,9 +90,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form");
expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "secret123");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await user.type(within(form!).getByLabelText("Password"), "secret123");
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith({
@ -108,6 +110,7 @@ describe("LoginPage integration", () => {
});
it("shows generic error message when login fails", async () => {
const user = userEvent.setup();
vi.mocked(authApi.login).mockRejectedValue(new Error("Invalid credentials"));
renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -118,9 +121,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form");
expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "wrong-password");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await user.type(within(form!).getByLabelText("Password"), "wrong-password");
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText("Invalid email or password. Please try again.")).toBeInTheDocument();
@ -130,6 +133,7 @@ describe("LoginPage integration", () => {
});
it("shows loading state while login is pending", async () => {
const user = userEvent.setup();
let resolveLogin: (value: AuthUser) => void = () => {};
vi.mocked(authApi.login).mockImplementation(
@ -147,9 +151,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form");
expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "secret123");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await user.type(within(form!).getByLabelText("Password"), "secret123");
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled();

View file

@ -0,0 +1,18 @@
import { Alert, ThemeProvider } from "@mui/material";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { muiTheme } from "@/lib/theme/mui-theme";
describe("MUI theme variants", () => {
it("applies the custom success palette when Alert uses severity", () => {
render(
<ThemeProvider theme={muiTheme}>
<Alert severity="success">Saved</Alert>
</ThemeProvider>,
);
const alert = screen.getByRole("alert");
expect(getComputedStyle(alert).backgroundColor).toBe("rgb(240, 253, 244)");
expect(getComputedStyle(alert).color).toBe("rgb(22, 101, 52)");
});
});

View file

@ -2,7 +2,7 @@ import { CssBaseline, ThemeProvider } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderOptions } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { MemoryRouter, type MemoryRouterProps } from "react-router-dom";
import { MemoryRouter, type MemoryRouterProps } from "react-router";
import { muiTheme } from "@/lib/theme/mui-theme";
import { AuthProvider } from "@/providers/auth-provider";

View file

@ -1,38 +0,0 @@
export function timeSince(dateString) {
if (!dateString) return "";
const d = new Date(dateString.endsWith("Z") ? dateString : dateString + "Z");
if (isNaN(d)) return "";
const now = new Date();
const diffMs = now - d;
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return `${Math.floor(days / 7)}w ago`;
}
export function timeBetween(startStr, endStr) {
if (!startStr || !endStr) return "";
const start = new Date(startStr.endsWith("Z") ? startStr : startStr + "Z");
const end = new Date(endStr.endsWith("Z") ? endStr : endStr + "Z");
if (isNaN(start) || isNaN(end)) return "";
const diffMs = end - start;
const mins = Math.floor(diffMs / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function waitTimeColor(dateString) {
if (!dateString) return "";
const d = new Date(dateString.endsWith("Z") ? dateString : dateString + "Z");
const hours = (new Date() - d) / 3600000;
if (hours < 4) return "green";
if (hours < 24) return "yellow";
return "red";
}

View file

@ -12,14 +12,11 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}

View file

@ -1,7 +1,7 @@
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
import { defineConfig, loadEnv, transformWithEsbuild } from "vite";
import { defineConfig, loadEnv } from "vite";
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
export default defineConfig(({ mode }) => {
@ -10,30 +10,7 @@ export default defineConfig(({ mode }) => {
assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL);
return {
plugins: [
{
name: "load-js-files-as-jsx",
async transform(code, id) {
if (!/\/src\/.*\.js$/.test(id)) {
return null;
}
return transformWithEsbuild(code, id, {
loader: "jsx",
jsx: "automatic",
});
},
},
react(),
tailwindcss(),
],
optimizeDeps: {
esbuildOptions: {
loader: {
".js": "jsx",
},
},
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),