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

2
.gitignore vendored
View file

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

View file

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

View file

@ -20,7 +20,18 @@ const legacyIgnores = [
export default tseslint.config( 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, js.configs.recommended,
...tseslint.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 - **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 deploys (matches the org's push-time-CD model). Gating deploy on CI is a
follow-up, not part of enabling CICD. follow-up, not part of enabling CICD.
- **Local npm is pinned to v6**; the committed `package-lock.json` is - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 1. CI (Node 24 / npm 11) reads it fine via `npm ci`. lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -1,134 +1,221 @@
{ {
"name": "shoc-frontend-infra", "name": "shoc-frontend-infra",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 1, "lockfileVersion": 3,
"requires": true, "requires": true,
"dependencies": { "packages": {
"@aws-cdk/asset-awscli-v1": { "": {
"version": "2.2.282", "name": "shoc-frontend-infra",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", "version": "0.1.0",
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g=="
},
"@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q=="
},
"@aws-cdk/cloud-assembly-schema": {
"version": "54.5.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz",
"integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==",
"requires": {
"jsonschema": "^1.5.0",
"semver": "^7.8.4"
},
"dependencies": { "dependencies": {
"jsonschema": { "aws-cdk-lib": "^2.261.0",
"version": "1.5.0", "constructs": "^10.4.2"
"bundled": true },
}, "bin": {
"semver": { "app": "bin/app.ts"
"version": "7.8.4", },
"bundled": true "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", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true, "dev": true,
"requires": { "license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
} }
}, },
"@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "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", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true "dev": true,
"license": "MIT"
}, },
"@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true, "dev": true,
"requires": { "license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"@tsconfig/node12": { "node_modules/@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true "dev": true,
"license": "MIT"
}, },
"@tsconfig/node14": { "node_modules/@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true "dev": true,
"license": "MIT"
}, },
"@tsconfig/node16": { "node_modules/@tsconfig/node16": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "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, "dev": true,
"requires": { "license": "MIT"
"undici-types": "~6.21.0" },
"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", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "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", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true, "dev": true,
"requires": { "license": "MIT",
"dependencies": {
"acorn": "^8.11.0" "acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
} }
}, },
"arg": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true "dev": true,
"license": "MIT"
}, },
"aws-cdk": { "node_modules/aws-cdk": {
"version": "2.1128.1", "version": "2.1130.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz", "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
"integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==", "integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
"dev": true "dev": true,
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
}
}, },
"aws-cdk-lib": { "node_modules/aws-cdk-lib": {
"version": "2.260.0", "version": "2.261.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz", "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
"integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==", "integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
"requires": { "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-awscli-v1": "2.2.282",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
"@aws-cdk/cloud-assembly-api": "^2.2.5", "@aws-cdk/cloud-assembly-api": "^2.2.5",
@ -142,231 +229,212 @@
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
"punycode": "^2.3.1", "punycode": "^2.3.1",
"semver": "^7.8.1", "semver": "^7.8.1",
"table": "^6.9.0",
"yaml": "1.10.3" "yaml": "1.10.3"
}, },
"dependencies": { "engines": {
"@aws-cdk/cloud-assembly-api": { "node": ">= 20.0.0"
"version": "2.2.5", },
"bundled": true, "peerDependencies": {
"requires": { "constructs": "^10.5.0"
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
}
},
"@balena/dockerignore": {
"version": "1.0.2",
"bundled": true
},
"ajv": {
"version": "8.20.0",
"bundled": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}
},
"ansi-regex": {
"version": "5.0.1",
"bundled": true
},
"ansi-styles": {
"version": "4.3.0",
"bundled": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"astral-regex": {
"version": "2.0.0",
"bundled": true
},
"balanced-match": {
"version": "4.0.4",
"bundled": true
},
"brace-expansion": {
"version": "5.0.6",
"bundled": true,
"requires": {
"balanced-match": "^4.0.2"
}
},
"case": {
"version": "1.6.3",
"bundled": true
},
"color-convert": {
"version": "2.0.1",
"bundled": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"bundled": true
},
"emoji-regex": {
"version": "8.0.0",
"bundled": true
},
"fast-deep-equal": {
"version": "3.1.3",
"bundled": true
},
"fast-uri": {
"version": "3.1.2",
"bundled": true
},
"fs-extra": {
"version": "11.3.5",
"bundled": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"graceful-fs": {
"version": "4.2.11",
"bundled": true
},
"ignore": {
"version": "5.3.2",
"bundled": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"bundled": true
},
"json-schema-traverse": {
"version": "1.0.0",
"bundled": true
},
"jsonfile": {
"version": "6.2.1",
"bundled": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"jsonschema": {
"version": "1.5.0",
"bundled": true
},
"lodash.truncate": {
"version": "4.4.2",
"bundled": true
},
"mime-db": {
"version": "1.52.0",
"bundled": true
},
"mime-types": {
"version": "2.1.35",
"bundled": true,
"requires": {
"mime-db": "1.52.0"
}
},
"minimatch": {
"version": "10.2.5",
"bundled": true,
"requires": {
"brace-expansion": "^5.0.5"
}
},
"punycode": {
"version": "2.3.1",
"bundled": true
},
"require-from-string": {
"version": "2.0.2",
"bundled": true
},
"semver": {
"version": "7.8.1",
"bundled": true
},
"slice-ansi": {
"version": "4.0.0",
"bundled": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
}
},
"string-width": {
"version": "4.2.3",
"bundled": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"bundled": true,
"requires": {
"ansi-regex": "^5.0.1"
}
},
"table": {
"version": "6.9.0",
"bundled": true,
"requires": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
}
},
"universalify": {
"version": "2.0.1",
"bundled": true
},
"yaml": {
"version": "1.10.3",
"bundled": true
}
} }
}, },
"constructs": { "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", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", "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", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "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", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true "dev": true,
"license": "ISC"
}, },
"ts-node": { "node_modules/ts-node": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"requires": { "license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7", "@tsconfig/node12": "^1.0.7",
@ -380,31 +448,67 @@
"make-error": "^1.1.1", "make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1", "v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.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": { "node_modules/typescript": {
"version": "5.8.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}, },
"undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true "dev": true,
"license": "MIT"
}, },
"v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true "dev": true,
"license": "MIT"
}, },
"yn": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
} }
} }
} }

View file

@ -6,6 +6,9 @@
"bin": { "bin": {
"app": "bin/app.ts" "app": "bin/app.ts"
}, },
"engines": {
"node": ">=22.22.1"
},
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"synth": "cdk synth", "synth": "cdk synth",
@ -13,13 +16,14 @@
"deploy": "cdk deploy" "deploy": "cdk deploy"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.30", "@types/node": "^24.13.3",
"aws-cdk": "^2.1021.0", "aws-cdk": "^2.1130.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.8.3" "typescript": "~6.0.3"
}, },
"dependencies": { "dependencies": {
"aws-cdk-lib": "^2.220.0", "aws-cdk-lib": "^2.261.0",
"constructs": "^10.4.2" "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/dm-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/montserrat": "^5.2.8", "@fontsource/montserrat": "^5.2.8",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.20", "@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2", "@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^7.3.1", "@mui/icons-material": "^9.2.0",
"@mui/material": "^7.3.1", "@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^14.6.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ky": "^1.7.5", "ky": "^2.0.2",
"lucide-react": "^1.17.0", "lucide-react": "^1.24.0",
"react": "^19.1.1", "react": "^19.2.7",
"react-dom": "^19.1.1", "react-dom": "^19.2.7",
"react-hook-form": "^7.78.0", "react-hook-form": "^7.81.0",
"react-icons": "^5.5.0", "react-icons": "^5.7.0",
"react-router-dom": "^7.9.4", "react-router": "^8.2.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.1.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"web-vitals": "^2.1.4",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^21.0.2", "@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.0.2", "@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.3.2",
"@types/node": "^22.15.30", "@types/node": "^24.13.3",
"@types/react": "^19.1.8", "@types/react": "^19.2.17",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.4.1", "eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.6.0", "globals": "^17.7.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^26.1.0", "jsdom": "^29.1.1",
"lint-staged": "^16.4.0", "lint-staged": "^17.0.8",
"prettier": "^3.8.4", "prettier": "^3.9.5",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.3.2",
"typescript": "^5.8.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.61.0", "typescript-eslint": "^8.63.0",
"vite": "^6.3.5", "vite": "^8.1.4",
"vitest": "^3.2.4" "vitest": "^4.1.10"
}, },
"engines": { "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, workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list", reporter: process.env.CI ? "github" : "list",
use: { 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", trace: "on-first-retry",
}, },
projects: [ projects: [
@ -18,8 +19,8 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: "npm run dev", command: "npm run dev -- --port 4173 --strictPort",
url: "http://localhost:3000", url: "http://localhost:4173",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
}, },

View file

@ -1,12 +1,4 @@
export class ApiError extends Error { import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function mapHttpStatusToMessage(status: number, data?: unknown): string { export function mapHttpStatusToMessage(status: number, data?: unknown): string {
if (status === 401) { if (status === 401) {
@ -33,13 +25,21 @@ export function mapHttpStatusToMessage(status: number, data?: unknown): string {
return "An error occurred"; return "An error occurred";
} }
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> { export function normalizeApiRequestError(error: Error): Error {
let data: unknown; if (isHTTPError(error)) {
try { error.message = mapHttpStatusToMessage(error.response.status, error.data);
data = await response.json(); return error;
} catch {
data = undefined;
} }
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 { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths"; 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 { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client"; import queryClient from "@/lib/query/query-client";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
@ -21,36 +21,23 @@ function isAuthLoginRequest(request: Request): boolean {
} }
export const api: KyInstance = ky.create({ export const api: KyInstance = ky.create({
prefixUrl: resolveApiPrefixUrl(env.apiUrl), prefix: resolveApiPrefixUrl(env.apiUrl),
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
(request) => { ({ request }) => {
const token = getAccessToken(); const token = getAccessToken();
if (token) { if (token) {
request.headers.set("Authorization", token); request.headers.set("Authorization", token);
} }
}, },
], ],
beforeError: [ beforeError: [({ error }) => normalizeApiRequestError(error)],
async (error: HTTPError) => {
const { response } = error;
if (!response) {
error.message = "No response from server. Please check your connection.";
return error;
}
const apiError = await createApiErrorFromResponse(response);
error.message = apiError.message;
return error;
},
],
afterResponse: [ afterResponse: [
async (request, _options, response) => { ({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request)) { if (response.status !== 401 || isAuthLoginRequest(request)) {
return response; 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"; import { GuestRoute } from "@/components/auth/guest-route";

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router";
import { import {
Alert, Alert,
Box, 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}> <Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
Cancel Cancel
</Button> </Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router";
import { import {
Alert, Alert,
Box, Box,
@ -218,7 +218,13 @@ export default function FollowUpFormPage() {
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} /> <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 <Button
variant="outlined" variant="outlined"
onClick={() => navigate("/followups")} onClick={() => navigate("/followups")}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { Alert, Box, Button, Typography } from "@mui/material"; import { Alert, Box, Button, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router";
export function PmScheduleEditUnavailablePanel() { export function PmScheduleEditUnavailablePanel() {
return ( return (
@ -8,7 +8,12 @@ export function PmScheduleEditUnavailablePanel() {
PM Schedule editing is unavailable — the production API does not expose{" "} PM Schedule editing is unavailable — the production API does not expose{" "}
<code className="text-sm">PMSchedule/GetById</code>. <code className="text-sm">PMSchedule/GetById</code>.
</Alert> </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{" "} 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 <code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
GetById. GetById.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router";
import { import {
Alert, Alert,
Box, Box,
@ -188,7 +188,13 @@ export default function VendorFormPage() {
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />} {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}> <Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
Cancel Cancel
</Button> </Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad"; import { SignaturePad } from "@/app/v/_components/signature-pad";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; 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() { export default function VendorPortalIndex() {
return <Navigate to="dashboard" replace />; return <Navigate to="dashboard" replace />;

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; 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 { 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 { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal"; import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import { 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 { useAuthContext } from "@/providers/auth-context";
import { Box, CircularProgress } from "@mui/material"; 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 { Box, CircularProgress } from "@mui/material";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import type { ComponentPropsWithoutRef } from "react"; 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 { Typography } from "@mui/material";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View file

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

View file

@ -1,5 +1,5 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; 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 { toast } from "react-toastify";
import { accountsApi } from "@/domain/accounts/api/accounts-api"; import { accountsApi } from "@/domain/accounts/api/accounts-api";
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema"; 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 { 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 { toast } from "react-toastify";
import { accountsApi } from "@/domain/accounts/api/accounts-api"; import { accountsApi } from "@/domain/accounts/api/accounts-api";
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema"; 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 { 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 { toast } from "react-toastify";
import { assetsApi } from "@/domain/assets/api/assets-api"; import { assetsApi } from "@/domain/assets/api/assets-api";
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema"; 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 { 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 { toast } from "react-toastify";
import { assetsApi } from "@/domain/assets/api/assets-api"; import { assetsApi } from "@/domain/assets/api/assets-api";
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema"; import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema";

View file

@ -5,7 +5,7 @@ import {
type UseMutationResult, type UseMutationResult,
type UseQueryResult, type UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api"; import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user"; 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 { 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 { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api"; import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema"; 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 { 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 { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api"; import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema"; 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 { 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 { toast } from "react-toastify";
import { contactsApi } from "@/domain/contacts/api/contacts-api"; import { contactsApi } from "@/domain/contacts/api/contacts-api";
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema"; 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 { 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 { toast } from "react-toastify";
import { contactsApi } from "@/domain/contacts/api/contacts-api"; import { contactsApi } from "@/domain/contacts/api/contacts-api";
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema"; 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 { 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 { toast } from "react-toastify";
import { employeesApi } from "@/domain/employees/api/employees-api"; import { employeesApi } from "@/domain/employees/api/employees-api";
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema"; 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 { 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 { toast } from "react-toastify";
import { employeesApi } from "@/domain/employees/api/employees-api"; import { employeesApi } from "@/domain/employees/api/employees-api";
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema"; 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 { 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 { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api"; import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema"; 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 { 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 { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api"; import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema"; 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 { 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 { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api"; import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; 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 { 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 { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api"; import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; 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 { 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 { toast } from "react-toastify";
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema"; 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 { 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 { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; 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 { 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 { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; 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 { 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 { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema"; 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: * Contract:
* - Relative paths (e.g. `/api`) are joined with the app origin. * - Relative paths (e.g. `/api`) are joined with the app origin.

View file

@ -168,11 +168,16 @@ const themeOptions: ThemeOptions = {
outline: `2px solid ${ring}`, outline: `2px solid ${ring}`,
outlineOffset: 2, outlineOffset: 2,
}, },
}, variants: [
containedPrimary: { {
"&:hover": { props: { variant: "contained", color: "primary" },
backgroundColor: primaryHover, style: {
}, "&:hover": {
backgroundColor: primaryHover,
},
},
},
],
}, },
outlined: { outlined: {
borderColor: border, borderColor: border,
@ -278,22 +283,36 @@ const themeOptions: ThemeOptions = {
root: { root: {
borderRadius: v("--radius-md", "6px"), borderRadius: v("--radius-md", "6px"),
fontSize: "0.875rem", fontSize: "0.875rem",
}, variants: [
standardSuccess: { {
backgroundColor: v("--status-completed-bg", "#f0fdf4"), props: { variant: "standard", severity: "success" },
color: v("--status-completed-text", "#166534"), style: {
}, backgroundColor: v("--status-completed-bg", "#f0fdf4"),
standardError: { color: v("--status-completed-text", "#166534"),
backgroundColor: v("--doc-no-bg", "#fff1f2"), },
color: v("--doc-no-text", "#be123c"), },
}, {
standardWarning: { props: { variant: "standard", severity: "error" },
backgroundColor: v("--status-pending-bg", "#fff7ed"), style: {
color: v("--status-pending-text", "#92400e"), backgroundColor: v("--doc-no-bg", "#fff1f2"),
}, color: v("--doc-no-text", "#be123c"),
standardInfo: { },
backgroundColor: v("--color-primary-tint", "#e6f4fb"), },
color: v("--sidebar-accent-foreground", "#155a92"), {
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"; 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 { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/(auth)/login"; import LoginPage from "@/app/(auth)/login";
@ -53,6 +53,7 @@ describe("LoginPage integration", () => {
}); });
it("keeps submit disabled until email and password are valid", async () => { it("keeps submit disabled until email and password are valid", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginRoutes />, { route: "/login" }); renderWithProviders(<LoginRoutes />, { route: "/login" });
await waitFor(() => { await waitFor(() => {
@ -68,16 +69,17 @@ describe("LoginPage integration", () => {
expect(submitButton).toBeDisabled(); expect(submitButton).toBeDisabled();
await userEvent.type(emailInput, "not-an-email"); await user.type(emailInput, "not-an-email");
await userEvent.type(passwordInput, "secret123"); await user.type(passwordInput, "secret123");
expect(submitButton).toBeDisabled(); expect(submitButton).toBeDisabled();
await userEvent.clear(emailInput); await user.clear(emailInput);
await userEvent.type(emailInput, "user@seahavenind.com"); await user.type(emailInput, "user@seahavenind.com");
expect(submitButton).toBeEnabled(); expect(submitButton).toBeEnabled();
}); });
it("logs in successfully and navigates to dashboard", async () => { it("logs in successfully and navigates to dashboard", async () => {
const user = userEvent.setup();
vi.mocked(authApi.login).mockResolvedValue(TEST_USER); vi.mocked(authApi.login).mockResolvedValue(TEST_USER);
renderWithProviders(<LoginRoutes />, { route: "/login" }); renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -88,9 +90,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form"); const form = document.querySelector("form");
expect(form).not.toBeNull(); expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "secret123"); await user.type(within(form!).getByLabelText("Password"), "secret123");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => { await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith({ expect(authApi.login).toHaveBeenCalledWith({
@ -108,6 +110,7 @@ describe("LoginPage integration", () => {
}); });
it("shows generic error message when login fails", async () => { it("shows generic error message when login fails", async () => {
const user = userEvent.setup();
vi.mocked(authApi.login).mockRejectedValue(new Error("Invalid credentials")); vi.mocked(authApi.login).mockRejectedValue(new Error("Invalid credentials"));
renderWithProviders(<LoginRoutes />, { route: "/login" }); renderWithProviders(<LoginRoutes />, { route: "/login" });
@ -118,9 +121,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form"); const form = document.querySelector("form");
expect(form).not.toBeNull(); expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "wrong-password"); await user.type(within(form!).getByLabelText("Password"), "wrong-password");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Invalid email or password. Please try again.")).toBeInTheDocument(); 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 () => { it("shows loading state while login is pending", async () => {
const user = userEvent.setup();
let resolveLogin: (value: AuthUser) => void = () => {}; let resolveLogin: (value: AuthUser) => void = () => {};
vi.mocked(authApi.login).mockImplementation( vi.mocked(authApi.login).mockImplementation(
@ -147,9 +151,9 @@ describe("LoginPage integration", () => {
const form = document.querySelector("form"); const form = document.querySelector("form");
expect(form).not.toBeNull(); expect(form).not.toBeNull();
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
await userEvent.type(within(form!).getByLabelText("Password"), "secret123"); await user.type(within(form!).getByLabelText("Password"), "secret123");
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); await user.click(within(form!).getByRole("button", { name: /sign in/i }));
await waitFor(() => { await waitFor(() => {
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled(); 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderOptions } from "@testing-library/react"; import { render, type RenderOptions } from "@testing-library/react";
import type { ReactElement, ReactNode } from "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 { muiTheme } from "@/lib/theme/mui-theme";
import { AuthProvider } from "@/providers/auth-provider"; 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, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"allowJs": true,
"checkJs": false,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"composite": true, "composite": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }

View file

@ -1,7 +1,7 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "node:path"; import path from "node:path";
import { defineConfig, loadEnv, transformWithEsbuild } from "vite"; import { defineConfig, loadEnv } from "vite";
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract"; import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
@ -10,30 +10,7 @@ export default defineConfig(({ mode }) => {
assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL); assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL);
return { return {
plugins: [ plugins: [react(), tailwindcss()],
{
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",
},
},
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "src"), "@": path.resolve(__dirname, "src"),