mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 10:53:13 +00:00
chore: upgrade frontend dependencies (#24)
* 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:
parent
166df904ac
commit
c68d47af7b
91 changed files with 3475 additions and 8256 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -42,5 +42,7 @@ infra/cdk/node_modules
|
|||
infra/cdk/cdk.out
|
||||
infra/cdk/cdk.context.json
|
||||
infra/cdk/*.d.ts
|
||||
infra/cdk/bin/*.d.ts
|
||||
infra/cdk/bin/*.js
|
||||
infra/cdk/lib/*.js
|
||||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ Vite + React SPA for SeaHaven facility management (work orders, vendor portal, u
|
|||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Node.js 22.22.1+
|
||||
- npm 11.16.0
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,18 @@ const legacyIgnores = [
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"infra/cdk/cdk.out/**",
|
||||
"infra/cdk/bin/**/*.d.ts",
|
||||
"infra/cdk/bin/**/*.js",
|
||||
"infra/cdk/lib/**/*.d.ts",
|
||||
"infra/cdk/lib/**/*.js",
|
||||
...legacyIgnores,
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
|
|
|||
|
|
@ -157,5 +157,5 @@ backend must also allow CORS from each frontend origin.
|
|||
- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model). Gating deploy on CI is a
|
||||
follow-up, not part of enabling CICD.
|
||||
- **Local npm is pinned to v6**; the committed `package-lock.json` is
|
||||
lockfileVersion 1. CI (Node 24 / npm 11) reads it fine via `npm ci`.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
|
|||
670
infra/cdk/package-lock.json
generated
670
infra/cdk/package-lock.json
generated
|
|
@ -1,134 +1,221 @@
|
|||
{
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g=="
|
||||
},
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
|
||||
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q=="
|
||||
},
|
||||
"@aws-cdk/cloud-assembly-schema": {
|
||||
"version": "54.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz",
|
||||
"integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==",
|
||||
"requires": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"bundled": true
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.8.4",
|
||||
"bundled": true
|
||||
}
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
},
|
||||
"@cspotcode/source-map-support": {
|
||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
|
||||
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema": {
|
||||
"version": "54.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz",
|
||||
"integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==",
|
||||
"bundleDependencies": [
|
||||
"jsonschema",
|
||||
"semver"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"@jridgewell/trace-mapping": {
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"@tsconfig/node10": {
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"@tsconfig/node12": {
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"@tsconfig/node14": {
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"@tsconfig/node16": {
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~6.21.0"
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"acorn-walk": {
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"arg": {
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"aws-cdk": {
|
||||
"version": "2.1128.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz",
|
||||
"integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==",
|
||||
"dev": true
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1130.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
|
||||
"integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cdk": "bin/cdk"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"aws-cdk-lib": {
|
||||
"version": "2.260.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz",
|
||||
"integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==",
|
||||
"requires": {
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.261.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
|
||||
"integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
|
||||
"bundleDependencies": [
|
||||
"@balena/dockerignore",
|
||||
"@aws-cdk/cloud-assembly-api",
|
||||
"case",
|
||||
"fs-extra",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"minimatch",
|
||||
"punycode",
|
||||
"semver",
|
||||
"yaml",
|
||||
"mime-types"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.282",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.5",
|
||||
|
|
@ -142,231 +229,212 @@
|
|||
"minimatch": "^10.2.5",
|
||||
"punycode": "^2.3.1",
|
||||
"semver": "^7.8.1",
|
||||
"table": "^6.9.0",
|
||||
"yaml": "1.10.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-cdk/cloud-assembly-api": {
|
||||
"version": "2.2.5",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.0"
|
||||
}
|
||||
},
|
||||
"@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true
|
||||
},
|
||||
"ajv": {
|
||||
"version": "8.20.0",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"bundled": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"bundled": true
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"bundled": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"case": {
|
||||
"version": "1.6.3",
|
||||
"bundled": true
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"bundled": true
|
||||
},
|
||||
"emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"bundled": true
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"bundled": true
|
||||
},
|
||||
"fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"bundled": true
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"bundled": true
|
||||
},
|
||||
"ignore": {
|
||||
"version": "5.3.2",
|
||||
"bundled": true
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"bundled": true
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true
|
||||
},
|
||||
"jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"graceful-fs": "^4.1.6",
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"bundled": true
|
||||
},
|
||||
"lodash.truncate": {
|
||||
"version": "4.4.2",
|
||||
"bundled": true
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.52.0",
|
||||
"bundled": true
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.35",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"mime-db": "1.52.0"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "10.2.5",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
}
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.3.1",
|
||||
"bundled": true
|
||||
},
|
||||
"require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"bundled": true
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.8.1",
|
||||
"bundled": true
|
||||
},
|
||||
"slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"astral-regex": "^2.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.2.3",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"version": "6.9.0",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"ajv": "^8.0.1",
|
||||
"lodash.truncate": "^4.4.2",
|
||||
"slice-ansi": "^4.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"universalify": {
|
||||
"version": "2.0.1",
|
||||
"bundled": true
|
||||
},
|
||||
"yaml": {
|
||||
"version": "1.10.3",
|
||||
"bundled": true
|
||||
}
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"constructs": "^10.5.0"
|
||||
}
|
||||
},
|
||||
"constructs": {
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
|
||||
"version": "2.2.5",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-cdk/cloud-assembly-schema": ">=53.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/case": {
|
||||
"version": "1.6.3",
|
||||
"inBundle": true,
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"inBundle": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"inBundle": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/constructs": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
|
||||
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ=="
|
||||
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"create-require": {
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"diff": {
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"make-error": {
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"ts-node": {
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
|
|
@ -380,31 +448,67 @@
|
|||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"v8-compile-cache-lib": {
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"yn": {
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"synth": "cdk synth",
|
||||
|
|
@ -13,13 +16,14 @@
|
|||
"deploy": "cdk deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"aws-cdk": "^2.1021.0",
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.8.3"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.220.0",
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "npm@11.16.0"
|
||||
}
|
||||
|
|
|
|||
9235
package-lock.json
generated
9235
package-lock.json
generated
File diff suppressed because it is too large
Load diff
83
package.json
83
package.json
|
|
@ -44,61 +44,60 @@
|
|||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
"@fullcalendar/interaction": "^6.1.21",
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@generouted/react-router": "^1.20.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.7.5",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"ky": "^2.0.2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.2.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^10.4.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.4",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"prettier": "^3.9.5",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"packageManager": "npm@11.16.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export default defineConfig({
|
|||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
// Port 4173 isolates Playwright from the app's normal port 3000 server.
|
||||
baseURL: "http://localhost:4173",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
|
|
@ -18,8 +19,8 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
command: "npm run dev -- --port 4173 --strictPort",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
|
||||
|
||||
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
||||
if (status === 401) {
|
||||
|
|
@ -33,13 +25,21 @@ export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
|||
return "An error occurred";
|
||||
}
|
||||
|
||||
export async function createApiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
export function normalizeApiRequestError(error: Error): Error {
|
||||
if (isHTTPError(error)) {
|
||||
error.message = mapHttpStatusToMessage(error.response.status, error.data);
|
||||
return error;
|
||||
}
|
||||
|
||||
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
if (isNetworkError(error)) {
|
||||
error.message = "No response from server. Please check your connection.";
|
||||
return error;
|
||||
}
|
||||
|
||||
if (isTimeoutError(error)) {
|
||||
error.message = "The request timed out. Please try again.";
|
||||
return error;
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
|
||||
import ky, { type KyInstance, type Options } from "ky";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
|
@ -21,36 +21,23 @@ function isAuthLoginRequest(request: Request): boolean {
|
|||
}
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
|
||||
prefix: resolveApiPrefixUrl(env.apiUrl),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
({ request }) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.headers.set("Authorization", token);
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
async (error: HTTPError) => {
|
||||
const { response } = error;
|
||||
|
||||
if (!response) {
|
||||
error.message = "No response from server. Please check your connection.";
|
||||
return error;
|
||||
}
|
||||
|
||||
const apiError = await createApiErrorFromResponse(response);
|
||||
error.message = apiError.message;
|
||||
return error;
|
||||
},
|
||||
],
|
||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||
afterResponse: [
|
||||
async (request, _options, response) => {
|
||||
({ request, response }) => {
|
||||
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
||||
return response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { GuestRoute } from "@/components/auth/guest-route";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -95,7 +95,13 @@ export default function AccountFormPage() {
|
|||
<Box className="mx-auto max-w-4xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={4}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? "Edit Account" : "Create an Account"}
|
||||
</Typography>
|
||||
|
|
@ -166,7 +172,13 @@ export default function AccountFormPage() {
|
|||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/accounts")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -70,20 +70,39 @@ export default function AccountsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Accounts
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -99,13 +118,11 @@ export default function AccountsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load accounts"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -132,7 +149,13 @@ export default function AccountsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No accounts found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No accounts found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -180,7 +203,6 @@ export default function AccountsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -193,7 +215,6 @@ export default function AccountsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Account</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -195,7 +195,13 @@ export default function AssetFormPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -68,20 +68,39 @@ export default function AssetsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Assets
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -97,13 +116,11 @@ export default function AssetsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load assets"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -129,7 +146,13 @@ export default function AssetsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No assets found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No assets found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -182,7 +205,6 @@ export default function AssetsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -195,7 +217,6 @@ export default function AssetsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Asset</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ export function CalendarUnavailablePanel() {
|
|||
<Alert severity="warning" className="mb-4">
|
||||
Calendário indisponível — a API de calendário ainda não está implementada no backend.
|
||||
</Alert>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
As rotas permanecem reservadas para quando o CalendarController for disponibilizado.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -128,10 +128,12 @@ export default function EventFormPage() {
|
|||
label="Title"
|
||||
required
|
||||
fullWidth
|
||||
inputProps={{ maxLength: 100 }}
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
{...register("title")}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 100 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
|
@ -145,8 +147,10 @@ export default function EventFormPage() {
|
|||
<TextField
|
||||
label="Location"
|
||||
fullWidth
|
||||
inputProps={{ maxLength: 150 }}
|
||||
{...register("location")}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 150 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
|
|
@ -193,7 +197,7 @@ export default function EventFormPage() {
|
|||
label="Start Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
error={Boolean(errors.startDate)}
|
||||
helperText={errors.startDate?.message}
|
||||
{...register("startDate")}
|
||||
|
|
@ -223,7 +227,7 @@ export default function EventFormPage() {
|
|||
label="End Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
error={Boolean(errors.endDate)}
|
||||
helperText={errors.endDate?.message}
|
||||
{...register("endDate")}
|
||||
|
|
@ -258,7 +262,13 @@ export default function EventFormPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/calendar")}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet } from "react-router";
|
||||
import { Alert, Box } from "@mui/material";
|
||||
|
||||
export default function CalendarLayout() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -238,7 +238,13 @@ export default function ContactFormPage() {
|
|||
<TextField label="Website" fullWidth placeholder="http://" {...register("website")} />
|
||||
<TextField label="SMS Address" fullWidth {...register("smsAddress")} />
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/contacts")}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -55,20 +55,39 @@ export default function ContactsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Contacts
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -84,13 +103,11 @@ export default function ContactsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load contacts"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -116,7 +133,13 @@ export default function ContactsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No contacts found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No contacts found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -155,7 +178,6 @@ export default function ContactsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -168,7 +190,6 @@ export default function ContactsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Contact</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
import {
|
||||
|
|
@ -71,7 +71,12 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
|||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -89,20 +94,31 @@ export default function DashboardPage() {
|
|||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||
<Stack
|
||||
direction={{ xs: "column", lg: "row" }}
|
||||
spacing={2}
|
||||
useFlexGap
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
title="Create a Work Order"
|
||||
description="Report a problem you need fixed"
|
||||
|
|
@ -135,13 +151,17 @@ export default function DashboardPage() {
|
|||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Priority Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Age of open Work Orders by Priority
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -183,7 +203,6 @@ export default function DashboardPage() {
|
|||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -178,7 +178,13 @@ export default function EmployeeFormPage() {
|
|||
<Box className="mx-auto max-w-5xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? "Edit an Employee" : "Create an Employee"}
|
||||
</Typography>
|
||||
|
|
@ -244,7 +250,7 @@ export default function EmployeeFormPage() {
|
|||
{...register("dateOfBirth")}
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<JobTitleSelect control={control} setValue={setValue} />
|
||||
|
|
@ -255,7 +261,7 @@ export default function EmployeeFormPage() {
|
|||
{...register("hireDate")}
|
||||
label="Hire Date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
<SelectField
|
||||
|
|
@ -271,7 +277,12 @@ export default function EmployeeFormPage() {
|
|||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Login & Role
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{isEdit
|
||||
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in."
|
||||
: "A confirmation email will be sent to activate the Employee's account."}
|
||||
|
|
@ -345,7 +356,13 @@ export default function EmployeeFormPage() {
|
|||
<TextField {...register("twitterUrl")} label="Twitter" fullWidth />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/employees")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -76,20 +76,39 @@ export default function EmployeesListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Employees
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -105,13 +124,11 @@ export default function EmployeesListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load employees"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -137,7 +154,13 @@ export default function EmployeesListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No employees found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No employees found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -191,7 +214,6 @@ export default function EmployeesListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -204,7 +226,6 @@ export default function EmployeesListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Employee</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -218,7 +218,13 @@ export default function FollowUpFormPage() {
|
|||
|
||||
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} />
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/followups")}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -64,20 +64,39 @@ export default function FollowUpsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Follow-ups
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -110,13 +129,11 @@ export default function FollowUpsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -143,7 +160,13 @@ export default function FollowUpsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No follow-ups found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No follow-ups found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -191,7 +214,6 @@ export default function FollowUpsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -204,7 +226,6 @@ export default function FollowUpsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Follow-up</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
import {
|
||||
|
|
@ -71,7 +71,12 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
|
|||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -89,20 +94,31 @@ export default function DashboardPage() {
|
|||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||
<Stack
|
||||
direction={{ xs: "column", lg: "row" }}
|
||||
spacing={2}
|
||||
useFlexGap
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
title="Create a Work Order"
|
||||
description="Report a problem you need fixed"
|
||||
|
|
@ -135,13 +151,17 @@ export default function DashboardPage() {
|
|||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Priority Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Age of open Work Orders by Priority
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -183,7 +203,6 @@ export default function DashboardPage() {
|
|||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
|
|
@ -99,7 +99,13 @@ export default function LocationFormPage() {
|
|||
<Box className="mx-auto max-w-4xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={4}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? "Edit Location" : "Create a Location"}
|
||||
</Typography>
|
||||
|
|
@ -251,7 +257,13 @@ export default function LocationFormPage() {
|
|||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -70,20 +70,39 @@ export default function LocationsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Locations
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -99,13 +118,11 @@ export default function LocationsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -132,7 +149,13 @@ export default function LocationsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No locations found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No locations found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -180,7 +203,6 @@ export default function LocationsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -193,7 +215,6 @@ export default function LocationsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Location</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -122,7 +122,7 @@ export default function PmScheduleFormPage() {
|
|||
type="date"
|
||||
required
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
error={Boolean(errors.startDate)}
|
||||
helperText={errors.startDate?.message}
|
||||
{...register("startDate")}
|
||||
|
|
@ -193,7 +193,13 @@ export default function PmScheduleFormPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export function PmScheduleEditUnavailablePanel() {
|
||||
return (
|
||||
|
|
@ -8,7 +8,12 @@ export function PmScheduleEditUnavailablePanel() {
|
|||
PM Schedule editing is unavailable — the production API does not expose{" "}
|
||||
<code className="text-sm">PMSchedule/GetById</code>.
|
||||
</Alert>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
List and create remain available via <code className="text-sm">GetList</code> and{" "}
|
||||
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
|
||||
GetById.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -72,20 +72,39 @@ export default function PmSchedulesListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
PM Schedules
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -103,13 +122,11 @@ export default function PmSchedulesListPage() {
|
|||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load PM schedules"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -137,7 +154,13 @@ export default function PmSchedulesListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No PM schedules found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No PM schedules found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -203,7 +226,6 @@ export default function PmSchedulesListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -216,7 +238,6 @@ export default function PmSchedulesListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
{pmScheduleCapabilities.canDelete && (
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete PM Schedule</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -120,16 +120,19 @@ export default function DropdownOptionsPage() {
|
|||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<SettingsNav />
|
||||
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Dropdown Options
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Manage configurable dropdown values for work orders
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
|
||||
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
|
||||
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
|
||||
|
|
@ -148,7 +151,13 @@ export default function DropdownOptionsPage() {
|
|||
</Stack>
|
||||
|
||||
<Box className="flex flex-1 flex-col gap-4 p-4">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{parentCategory && (
|
||||
<FormControl size="small" className="min-w-[160px]">
|
||||
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
|
||||
|
|
@ -199,11 +208,22 @@ export default function DropdownOptionsPage() {
|
|||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Stack alignItems="center" className="py-10">
|
||||
<Stack
|
||||
className="py-10"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
</Stack>
|
||||
) : options.length === 0 ? (
|
||||
<Typography align="center" color="text.secondary" className="py-10">
|
||||
<Typography
|
||||
align="center"
|
||||
className="py-10"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No options configured yet
|
||||
</Typography>
|
||||
) : (
|
||||
|
|
@ -231,7 +251,14 @@ export default function DropdownOptionsPage() {
|
|||
className="flex-1"
|
||||
/>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
className="min-w-0"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" className="truncate">
|
||||
{item.value}
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -115,20 +115,22 @@ export default function TaskTemplatesPage() {
|
|||
return (
|
||||
<Box className="p-4">
|
||||
<SettingsNav />
|
||||
|
||||
<Stack spacing={1} className="mb-4">
|
||||
<Typography variant="h5">Task List Templates</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Create reusable checklists for work order dispatches
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" className="mb-4">
|
||||
{error instanceof Error ? error.message : "Failed to load templates"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
|
||||
<Paper variant="outlined" className="p-3">
|
||||
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
|
||||
|
|
@ -158,7 +160,12 @@ export default function TaskTemplatesPage() {
|
|||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
{!selectedId ? (
|
||||
<Typography color="text.secondary" className="py-10 text-center">
|
||||
<Typography
|
||||
className="py-10 text-center"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Select a template or create a new one
|
||||
</Typography>
|
||||
) : (
|
||||
|
|
@ -236,7 +243,13 @@ export default function TaskTemplatesPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{!isNew && (
|
||||
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
|
||||
Delete
|
||||
|
|
@ -254,7 +267,6 @@ export default function TaskTemplatesPage() {
|
|||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -96,14 +96,27 @@ export default function UpliftQueuePage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Uplift Queue
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
|
|
@ -144,13 +157,11 @@ export default function UpliftQueuePage() {
|
|||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load uplift queue"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -177,7 +188,11 @@ export default function UpliftQueuePage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" className="py-10">
|
||||
<Typography color="text.secondary">
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No uplift requests match these filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
|
@ -257,7 +272,6 @@ export default function UpliftQueuePage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -266,7 +280,6 @@ export default function UpliftQueuePage() {
|
|||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[25]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -33,7 +33,13 @@ function formatMoney(value: number | null | undefined): string {
|
|||
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
|
|
@ -107,7 +113,14 @@ export default function VendorPoViewPage() {
|
|||
|
||||
return (
|
||||
<Box className="mx-auto max-w-5xl p-4">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-4"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Button variant="text" onClick={() => navigate(-1)}>
|
||||
← Back
|
||||
</Button>
|
||||
|
|
@ -115,27 +128,47 @@ export default function VendorPoViewPage() {
|
|||
Print
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1} className="mb-4">
|
||||
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Dispatch {dispatch.dispatchNumber || "—"}
|
||||
</Typography>
|
||||
<Chip size="small" label={dispatch.status || "—"} />
|
||||
{vendor?.companyName && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
· {vendor.companyName}
|
||||
</Typography>
|
||||
)}
|
||||
{dispatch.nteAmount != null && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
· NTE {formatMoney(dispatch.nteAmount)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
|
|
@ -153,7 +186,13 @@ export default function VendorPoViewPage() {
|
|||
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No vendor attached.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendor attached.
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
|
|
@ -175,7 +214,12 @@ export default function VendorPoViewPage() {
|
|||
</Box>
|
||||
{dispatch.description && (
|
||||
<Box className="mt-3">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||
|
|
@ -211,7 +255,13 @@ export default function VendorPoViewPage() {
|
|||
Linked Work Orders ({dispatch.workOrders.length})
|
||||
</Typography>
|
||||
{dispatch.workOrders.length === 0 ? (
|
||||
<Typography color="text.secondary">No work orders linked.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No work orders linked.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -251,19 +301,33 @@ export default function VendorPoViewPage() {
|
|||
NTE Uplift History ({dispatch.upliftRequests.length})
|
||||
</Typography>
|
||||
{dispatch.upliftRequests.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No uplift requests on this PO.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.upliftRequests.map((u) => (
|
||||
<Box key={String(u.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
className="ml-2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
|
||||
</Typography>
|
||||
|
|
@ -307,7 +371,6 @@ export default function VendorPoViewPage() {
|
|||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -92,14 +92,27 @@ export default function VendorPosListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendor POs
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
|
|
@ -116,8 +129,13 @@ export default function VendorPosListPage() {
|
|||
className="min-w-[280px]"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} flexWrap="wrap">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
|
|
@ -181,13 +199,11 @@ export default function VendorPosListPage() {
|
|||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -213,7 +229,13 @@ export default function VendorPosListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No vendor POs found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendor POs found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -263,7 +285,6 @@ export default function VendorPosListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -188,7 +188,13 @@ export default function VendorFormPage() {
|
|||
|
||||
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,13 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary" className="mt-2">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="mt-2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Loading portal link…
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
|
@ -64,7 +70,13 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Vendor Portal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="mt-1 mb-3"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No active portal link. Generate one so this vendor can access the portal.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
|
||||
|
|
@ -79,7 +91,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Vendor Portal
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
|
||||
<TextField
|
||||
fullWidth
|
||||
|
|
@ -92,15 +103,20 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
Copy
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="mb-3"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
|
||||
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
|
||||
{tokenInfo.lastUsedAt
|
||||
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
|
||||
: " · Never used"}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -119,7 +135,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
Revoke
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
|
||||
<DialogTitle>Rotate Portal Token</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
@ -141,7 +156,6 @@ export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
|
||||
<DialogTitle>Revoke Portal Tokens</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
45
src/app/(protected)/vendors/index.tsx
vendored
45
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -61,20 +61,39 @@ export default function VendorsListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendors
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -90,13 +109,11 @@ export default function VendorsListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendors"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -122,7 +139,13 @@ export default function VendorsListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No vendors found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendors found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -175,7 +198,6 @@ export default function VendorsListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
@ -188,7 +210,6 @@ export default function VendorsListPage() {
|
|||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
|
|
@ -63,18 +63,37 @@ function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
|||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography color="text.secondary">No comments yet.</Typography>;
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No comments yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((comment) => (
|
||||
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" className="mb-1">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
|
@ -95,18 +114,37 @@ function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
|||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography color="text.secondary">No changes recorded.</Typography>;
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No changes recorded.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" className="mb-1">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
|
@ -183,14 +221,21 @@ export default function WorkOrderViewPage() {
|
|||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography color="text.primary">{wo.internalWONumber}</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{wo.internalWONumber}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "stretch", md: "flex-start" }}
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "stretch", md: "flex-start" },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
|
|
@ -224,7 +269,13 @@ export default function WorkOrderViewPage() {
|
|||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
||||
Dispatch
|
||||
</Button>
|
||||
|
|
@ -236,7 +287,6 @@ export default function WorkOrderViewPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Details
|
||||
|
|
@ -247,7 +297,13 @@ export default function WorkOrderViewPage() {
|
|||
<Field label="Priority" value={wo.priority || "—"} />
|
||||
<Field label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||
|
|
@ -284,10 +340,15 @@ export default function WorkOrderViewPage() {
|
|||
<Field label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Classification</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Classification
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
|
|
@ -297,10 +358,15 @@ export default function WorkOrderViewPage() {
|
|||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Schedule</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -314,10 +380,15 @@ export default function WorkOrderViewPage() {
|
|||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Financial</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Financial
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
|
|
@ -330,10 +401,15 @@ export default function WorkOrderViewPage() {
|
|||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Dispatches ({wo.dispatches.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
|
|
@ -393,20 +469,30 @@ export default function WorkOrderViewPage() {
|
|||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography color="text.secondary">No dispatches yet.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No dispatches yet.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Description</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Activity
|
||||
|
|
@ -446,7 +532,6 @@ export default function WorkOrderViewPage() {
|
|||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{dispatchOpen && (
|
||||
<DispatchCreateModal
|
||||
workOrder={wo}
|
||||
|
|
@ -454,7 +539,6 @@ export default function WorkOrderViewPage() {
|
|||
onDispatched={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dispatchDetailId && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={dispatchDetailId}
|
||||
|
|
@ -470,7 +554,13 @@ export default function WorkOrderViewPage() {
|
|||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
|
|
|
|||
|
|
@ -138,13 +138,18 @@ export function DispatchCreateModal({
|
|||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-4">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-2">
|
||||
Work Orders ({selectedWOs.length})
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{selectedWOs.map((wo) => (
|
||||
<Chip
|
||||
key={String(wo.id)}
|
||||
|
|
@ -203,9 +208,21 @@ export function DispatchCreateModal({
|
|||
className="mb-2"
|
||||
/>
|
||||
{isLoadingVendors ? (
|
||||
<Typography color="text.secondary">Loading vendors...</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Loading vendors...
|
||||
</Typography>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<Typography color="text.secondary">No vendors found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendors found
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
||||
{filteredVendors.map((vendor) => (
|
||||
|
|
@ -226,12 +243,24 @@ export function DispatchCreateModal({
|
|||
{vendor.companyName}
|
||||
</Typography>
|
||||
{vendor.tradeSpecialties && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{vendor.tradeSpecialties}
|
||||
</Typography>
|
||||
)}
|
||||
{vendor.address && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{vendor.address}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -253,8 +282,10 @@ export function DispatchCreateModal({
|
|||
type="number"
|
||||
value={nteAmount}
|
||||
onChange={(event) => setNteAmount(event.target.value)}
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01", min: 0 },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Vendor Scheduled Date"
|
||||
|
|
@ -282,7 +313,13 @@ export function DispatchCreateModal({
|
|||
</TextField>
|
||||
|
||||
{customItems.length > 0 && (
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{customItems.map((item, index) => (
|
||||
<Chip
|
||||
key={`${item}-${index}`}
|
||||
|
|
@ -317,7 +354,6 @@ export function DispatchCreateModal({
|
|||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -207,7 +207,12 @@ export function DispatchDetailModal({
|
|||
<Typography variant="h6">
|
||||
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
PO: {dispatch.poNumber} ·{" "}
|
||||
{dispatch.workOrders.length > 0
|
||||
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
||||
|
|
@ -247,7 +252,9 @@ export function DispatchDetailModal({
|
|||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, nteAmount: event.target.value }))
|
||||
}
|
||||
inputProps={{ step: "0.01" }}
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01" },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
|
|
@ -372,7 +379,14 @@ export function DispatchDetailModal({
|
|||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{dispatch.checklistItems.map((item) => (
|
||||
<Stack key={String(item.id)} direction="row" spacing={1} alignItems="center">
|
||||
<Stack
|
||||
key={String(item.id)}
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.isCompleted}
|
||||
disabled={isLocked || updateChecklistMutation.isPending}
|
||||
|
|
@ -387,7 +401,12 @@ export function DispatchDetailModal({
|
|||
<Box className="flex-1">
|
||||
<Typography variant="body2">{item.itemText}</Typography>
|
||||
{item.isCompleted && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{item.completedBy}
|
||||
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
||||
</Typography>
|
||||
|
|
@ -441,7 +460,12 @@ export function DispatchDetailModal({
|
|||
{signoff.signature}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{signoff.name}
|
||||
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
||||
</Typography>
|
||||
|
|
@ -471,7 +495,13 @@ export function DispatchDetailModal({
|
|||
NTE Uplift Requests
|
||||
</Typography>
|
||||
{uplifts.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No uplift requests.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{uplifts.map((uplift) => {
|
||||
|
|
@ -479,7 +509,14 @@ export function DispatchDetailModal({
|
|||
|
||||
return (
|
||||
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
|
|
@ -496,7 +533,13 @@ export function DispatchDetailModal({
|
|||
</Typography>
|
||||
)}
|
||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="mt-1"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
|
|
@ -505,7 +548,12 @@ export function DispatchDetailModal({
|
|||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
<Box className="mt-1">
|
||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
|
|
@ -564,16 +612,32 @@ export function DispatchDetailModal({
|
|||
</Typography>
|
||||
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
||||
{dispatch.comments.length === 0 ? (
|
||||
<Typography color="text.secondary">No messages yet.</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No messages yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.comments.map((comment) => (
|
||||
<Box key={String(comment.id)}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
|
@ -595,9 +659,11 @@ export function DispatchDetailModal({
|
|||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
className="mt-2"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
|
@ -625,9 +691,20 @@ export function DispatchDetailModal({
|
|||
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{verifyState.missing.length > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Missing: {verifyState.missing.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -643,7 +720,6 @@ export function DispatchDetailModal({
|
|||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{sigCaptureType && (
|
||||
<SignatureCapture
|
||||
title={`${sigCaptureType} Sign-off`}
|
||||
|
|
@ -687,7 +763,13 @@ function DetailField({
|
|||
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={colorClass}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
|
|
@ -131,13 +131,17 @@ export default function WorkOrderFormPage() {
|
|||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{isEdit ? "Edit" : "Create"}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||
<Box>
|
||||
|
|
@ -315,8 +319,10 @@ export default function WorkOrderFormPage() {
|
|||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01", min: 0 },
|
||||
}}
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField
|
||||
|
|
@ -340,7 +346,13 @@ export default function WorkOrderFormPage() {
|
|||
/>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||
|
|
@ -120,20 +120,39 @@ export default function WorkOrdersListPage() {
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Work Orders
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
|
|
@ -149,8 +168,15 @@ export default function WorkOrdersListPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
useFlexGap
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label="All"
|
||||
clickable
|
||||
|
|
@ -193,13 +219,11 @@ export default function WorkOrdersListPage() {
|
|||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load work orders"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
|
@ -237,7 +261,13 @@ export default function WorkOrdersListPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No work orders found</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No work orders found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -292,7 +322,6 @@ export default function WorkOrdersListPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Link } from "react-router";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { NavLink, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation, useParams } from "react-router";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
|
||||
import "@/app/v/_components/vendor-portal.css";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate } from "react-router";
|
||||
|
||||
export default function VendorPortalIndex() {
|
||||
return <Navigate to="dashboard" replace />;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate } from "react-router";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate } from "react-router";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
|
@ -33,10 +33,19 @@ export function CalendarActionBar({
|
|||
<Stack
|
||||
direction={{ xs: "column", lg: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", lg: "center" }}
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", lg: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
startIcon={<InboxIcon />}
|
||||
onClick={(event) => setWorkspaceAnchor(event.currentTarget)}
|
||||
|
|
@ -99,7 +108,14 @@ export function CalendarActionBar({
|
|||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" className="min-w-[180px]">
|
||||
{title}
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink } from "react-router";
|
||||
import { Stack } from "@mui/material";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import {
|
||||
|
|
@ -169,7 +169,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
|||
/>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
|
||||
<Menu
|
||||
anchorEl={userAnchor}
|
||||
open={Boolean(userAnchor)}
|
||||
|
|
@ -199,10 +198,20 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
|||
{userInitials}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user?.fullname || "User"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{user?.email || ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@ export function EmptyState({ icon, title, description, action, className }: Empt
|
|||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary" className="max-w-sm">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="max-w-sm"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ export function PageHeader({ eyebrow, title, subtitle, actions, className }: Pag
|
|||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
className={cn("mb-4", className)}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5}>
|
||||
{eyebrow && (
|
||||
|
|
@ -30,13 +32,24 @@ export function PageHeader({ eyebrow, title, subtitle, actions, className }: Pag
|
|||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { accountsApi } from "@/domain/accounts/api/accounts-api";
|
||||
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { accountsApi } from "@/domain/accounts/api/accounts-api";
|
||||
import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { assetsApi } from "@/domain/assets/api/assets-api";
|
||||
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { assetsApi } from "@/domain/assets/api/assets-api";
|
||||
import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { calendarApi } from "@/domain/calendar/api/calendar-api";
|
||||
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { calendarApi } from "@/domain/calendar/api/calendar-api";
|
||||
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { contactsApi } from "@/domain/contacts/api/contacts-api";
|
||||
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { contactsApi } from "@/domain/contacts/api/contacts-api";
|
||||
import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
|
||||
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
|
||||
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
|
||||
import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function getDefaultOrigin(): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolves VITE_API_URL to a Ky-compatible prefixUrl (always ends with /).
|
||||
* Resolves VITE_API_URL to a Ky-compatible prefix (always ends with /).
|
||||
*
|
||||
* Contract:
|
||||
* - Relative paths (e.g. `/api`) are joined with the app origin.
|
||||
|
|
|
|||
|
|
@ -168,11 +168,16 @@ const themeOptions: ThemeOptions = {
|
|||
outline: `2px solid ${ring}`,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
containedPrimary: {
|
||||
"&:hover": {
|
||||
backgroundColor: primaryHover,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
props: { variant: "contained", color: "primary" },
|
||||
style: {
|
||||
"&:hover": {
|
||||
backgroundColor: primaryHover,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
outlined: {
|
||||
borderColor: border,
|
||||
|
|
@ -278,22 +283,36 @@ const themeOptions: ThemeOptions = {
|
|||
root: {
|
||||
borderRadius: v("--radius-md", "6px"),
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
standardSuccess: {
|
||||
backgroundColor: v("--status-completed-bg", "#f0fdf4"),
|
||||
color: v("--status-completed-text", "#166534"),
|
||||
},
|
||||
standardError: {
|
||||
backgroundColor: v("--doc-no-bg", "#fff1f2"),
|
||||
color: v("--doc-no-text", "#be123c"),
|
||||
},
|
||||
standardWarning: {
|
||||
backgroundColor: v("--status-pending-bg", "#fff7ed"),
|
||||
color: v("--status-pending-text", "#92400e"),
|
||||
},
|
||||
standardInfo: {
|
||||
backgroundColor: v("--color-primary-tint", "#e6f4fb"),
|
||||
color: v("--sidebar-accent-foreground", "#155a92"),
|
||||
variants: [
|
||||
{
|
||||
props: { variant: "standard", severity: "success" },
|
||||
style: {
|
||||
backgroundColor: v("--status-completed-bg", "#f0fdf4"),
|
||||
color: v("--status-completed-text", "#166534"),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { variant: "standard", severity: "error" },
|
||||
style: {
|
||||
backgroundColor: v("--doc-no-bg", "#fff1f2"),
|
||||
color: v("--doc-no-text", "#be123c"),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { variant: "standard", severity: "warning" },
|
||||
style: {
|
||||
backgroundColor: v("--status-pending-bg", "#fff7ed"),
|
||||
color: v("--status-pending-text", "#92400e"),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { variant: "standard", severity: "info" },
|
||||
style: {
|
||||
backgroundColor: v("--color-primary-tint", "#e6f4fb"),
|
||||
color: v("--sidebar-accent-foreground", "#155a92"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
54
src/test/api/api-error.test.ts
Normal file
54
src/test/api/api-error.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import LoginPage from "@/app/(auth)/login";
|
||||
|
|
@ -53,6 +53,7 @@ describe("LoginPage integration", () => {
|
|||
});
|
||||
|
||||
it("keeps submit disabled until email and password are valid", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginRoutes />, { route: "/login" });
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -68,16 +69,17 @@ describe("LoginPage integration", () => {
|
|||
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(emailInput, "not-an-email");
|
||||
await userEvent.type(passwordInput, "secret123");
|
||||
await user.type(emailInput, "not-an-email");
|
||||
await user.type(passwordInput, "secret123");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.clear(emailInput);
|
||||
await userEvent.type(emailInput, "user@seahavenind.com");
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, "user@seahavenind.com");
|
||||
expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("logs in successfully and navigates to dashboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(authApi.login).mockResolvedValue(TEST_USER);
|
||||
|
||||
renderWithProviders(<LoginRoutes />, { route: "/login" });
|
||||
|
|
@ -88,9 +90,9 @@ describe("LoginPage integration", () => {
|
|||
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await userEvent.type(within(form!).getByLabelText("Password"), "secret123");
|
||||
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await user.type(within(form!).getByLabelText("Password"), "secret123");
|
||||
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApi.login).toHaveBeenCalledWith({
|
||||
|
|
@ -108,6 +110,7 @@ describe("LoginPage integration", () => {
|
|||
});
|
||||
|
||||
it("shows generic error message when login fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error("Invalid credentials"));
|
||||
|
||||
renderWithProviders(<LoginRoutes />, { route: "/login" });
|
||||
|
|
@ -118,9 +121,9 @@ describe("LoginPage integration", () => {
|
|||
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await userEvent.type(within(form!).getByLabelText("Password"), "wrong-password");
|
||||
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await user.type(within(form!).getByLabelText("Password"), "wrong-password");
|
||||
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invalid email or password. Please try again.")).toBeInTheDocument();
|
||||
|
|
@ -130,6 +133,7 @@ describe("LoginPage integration", () => {
|
|||
});
|
||||
|
||||
it("shows loading state while login is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveLogin: (value: AuthUser) => void = () => {};
|
||||
|
||||
vi.mocked(authApi.login).mockImplementation(
|
||||
|
|
@ -147,9 +151,9 @@ describe("LoginPage integration", () => {
|
|||
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await userEvent.type(within(form!).getByLabelText("Password"), "secret123");
|
||||
await userEvent.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
await user.type(within(form!).getByLabelText("Email"), "user@seahavenind.com");
|
||||
await user.type(within(form!).getByLabelText("Password"), "secret123");
|
||||
await user.click(within(form!).getByRole("button", { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled();
|
||||
|
|
|
|||
18
src/test/lib/mui-theme.test.tsx
Normal file
18
src/test/lib/mui-theme.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { CssBaseline, ThemeProvider } from "@mui/material";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { MemoryRouter, type MemoryRouterProps } from "react-router-dom";
|
||||
import { MemoryRouter, type MemoryRouterProps } from "react-router";
|
||||
|
||||
import { muiTheme } from "@/lib/theme/mui-theme";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -12,14 +12,11 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "node:path";
|
||||
import { defineConfig, loadEnv, transformWithEsbuild } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
|
|
@ -10,30 +10,7 @@ export default defineConfig(({ mode }) => {
|
|||
assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL);
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
name: "load-js-files-as-jsx",
|
||||
async transform(code, id) {
|
||||
if (!/\/src\/.*\.js$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return transformWithEsbuild(code, id, {
|
||||
loader: "jsx",
|
||||
jsx: "automatic",
|
||||
});
|
||||
},
|
||||
},
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
loader: {
|
||||
".js": "jsx",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue