Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e76a32eafe | |||
| 23d4c6fc92 |
Generated
+315
@@ -28,6 +28,7 @@
|
|||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
|
"typescript-eslint": "^8.60.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1658,6 +1659,285 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
|
"@typescript-eslint/scope-manager": "8.60.0",
|
||||||
|
"@typescript-eslint/type-utils": "8.60.0",
|
||||||
|
"@typescript-eslint/utils": "8.60.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"natural-compare": "^1.4.0",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/parser": "^8.60.0",
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/parser": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/scope-manager": "8.60.0",
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/tsconfig-utils": "^8.60.0",
|
||||||
|
"@typescript-eslint/types": "^8.60.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.60.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||||
|
"@typescript-eslint/utils": "8.60.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/types": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/project-service": "8.60.0",
|
||||||
|
"@typescript-eslint/tsconfig-utils": "8.60.0",
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.60.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"semver": "^7.7.3",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
|
"version": "10.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
|
"version": "7.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||||
|
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/utils": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
|
"@typescript-eslint/scope-manager": "8.60.0",
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.60.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.60.0",
|
||||||
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -3504,6 +3784,18 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-api-utils": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -3529,6 +3821,29 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript-eslint": {
|
||||||
|
"version": "8.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
|
||||||
|
"integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||||
|
"@typescript-eslint/parser": "8.60.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.60.0",
|
||||||
|
"@typescript-eslint/utils": "8.60.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
|
"typescript-eslint": "^8.60.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-33
@@ -1,53 +1,48 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
|
||||||
import { Package, Settings } from 'lucide-react'
|
import { Box, Printer } from 'lucide-react'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
import InventoryPage from './pages/InventoryPage'
|
import InventoryPage from './pages/InventoryPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import PrintJobsPage from './pages/PrintJobsPage'
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ComponentType<{ size?: number }>; label: string }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-600/20 text-emerald-300 border border-emerald-700'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={16} /> {label}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen bg-slate-900 text-slate-50">
|
<div className="min-h-screen bg-slate-900 text-slate-50">
|
||||||
<header className="bg-slate-800 border-b border-slate-700 px-4 py-3 flex items-center gap-4 sticky top-0 z-20">
|
<header className="bg-slate-800 border-b border-slate-700 px-4 py-3 flex items-center gap-3 sticky top-0 z-20">
|
||||||
<div className="flex items-center gap-3 mr-4">
|
|
||||||
<div className="w-8 h-8 rounded bg-emerald-500 flex items-center justify-center text-slate-900 font-bold text-lg">E</div>
|
<div className="w-8 h-8 rounded bg-emerald-500 flex items-center justify-center text-slate-900 font-bold text-lg">E</div>
|
||||||
<h1 className="text-lg font-semibold">Extrudex</h1>
|
<h1 className="text-lg font-semibold">Extrudex</h1>
|
||||||
</div>
|
<nav className="ml-6 flex items-center gap-1">
|
||||||
<nav className="flex items-center gap-1">
|
<NavItem to="/" icon={Box} label="Dashboard" />
|
||||||
<NavLink
|
<NavItem to="/inventory" icon={Box} label="Inventory" />
|
||||||
to="/"
|
<NavItem to="/print-jobs" icon={Printer} label="Print Jobs" />
|
||||||
end
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-emerald-600/20 text-emerald-400'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Package size={15} /> Inventory
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/settings"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-emerald-600/20 text-emerald-400'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Settings size={15} /> Settings
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="p-4">
|
<main className="p-4">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<InventoryPage />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/inventory" element={<InventoryPage />} />
|
||||||
|
<Route path="/print-jobs" element={<PrintJobsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Search, Filter, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
|
import { fetchPrintJobs } from '../services/printJobService'
|
||||||
|
import type { PrintJob, PrintJobFilter, PrintJobStatus } from '../types/printJob'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type SortField = 'name' | 'started_at' | 'duration_seconds' | 'filament_grams_used' | 'cost_usd'
|
||||||
|
type SortDir = 'asc' | 'desc'
|
||||||
|
|
||||||
|
// ── Status helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<PrintJobStatus, { label: string; badge: string; icon: string }> = {
|
||||||
|
pending: { label: 'Pending', badge: 'bg-slate-700 text-slate-300 border-slate-600', icon: '⏳' },
|
||||||
|
printing: { label: 'Printing', badge: 'bg-blue-900/40 text-blue-300 border-blue-700', icon: '🖨️' },
|
||||||
|
completed: { label: 'Completed', badge: 'bg-emerald-900/30 text-emerald-300 border-emerald-700', icon: '✅' },
|
||||||
|
failed: { label: 'Failed', badge: 'bg-red-900/40 text-red-300 border-red-700', icon: '❌' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: PrintJobStatus }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${cfg.badge}`}>
|
||||||
|
<span className="text-xs">{cfg.icon}</span> {cfg.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Duration formatter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDuration(totalSeconds?: number): string {
|
||||||
|
if (totalSeconds == null || totalSeconds <= 0) return '—'
|
||||||
|
const h = Math.floor(totalSeconds / 3600)
|
||||||
|
const m = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const s = totalSeconds % 60
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
if (m > 0) return `${m}m ${s}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PrintJobsPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<PrintJobStatus | ''>('')
|
||||||
|
const [printerFilter, setPrinterFilter] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<SortField>('started_at')
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const filter: PrintJobFilter = useMemo(() => ({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
printer: printerFilter || undefined,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_dir: sortDir,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: page * PAGE_SIZE,
|
||||||
|
}), [statusFilter, printerFilter, sortBy, sortDir, page])
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['printJobs', filter],
|
||||||
|
queryFn: () => fetchPrintJobs(filter),
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = data?.total ?? 0
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
// Client-side search (name + printer name)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const jobList = data?.data ?? []
|
||||||
|
if (!search.trim()) return jobList
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return jobList.filter(
|
||||||
|
(j: PrintJob) =>
|
||||||
|
j.name.toLowerCase().includes(q) ||
|
||||||
|
j.printer_name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [data?.data, search])
|
||||||
|
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortBy(field)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortIndicator = ({ field }: { field: SortField }) => {
|
||||||
|
if (sortBy !== field) return <span className="text-slate-600 ml-1">↕</span>
|
||||||
|
return <span className="text-emerald-400 ml-1">{sortDir === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-100">Print Jobs</h2>
|
||||||
|
<p className="text-sm text-slate-400">{total} job(s) total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by job or printer name…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(0) }}
|
||||||
|
className="w-full rounded-lg bg-slate-800 border border-slate-700 pl-9 pr-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => { setStatusFilter(e.target.value as PrintJobStatus | ''); setPage(0) }}
|
||||||
|
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="printing">Printing</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Printer filter */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by printer…"
|
||||||
|
value={printerFilter}
|
||||||
|
onChange={e => { setPrinterFilter(e.target.value); setPage(0) }}
|
||||||
|
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 min-w-[160px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter indicator */}
|
||||||
|
{(statusFilter || printerFilter) && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-900/30 border border-emerald-700 px-3 py-2 text-xs text-emerald-300">
|
||||||
|
<Filter size={14} />
|
||||||
|
{[statusFilter && `Status: ${statusFilter}`, printerFilter && `Printer: ${printerFilter}`].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12 gap-2 text-slate-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" /> Loading print jobs…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-12 text-red-400">
|
||||||
|
Failed to load print jobs.
|
||||||
|
<button onClick={() => refetch()} className="ml-2 underline hover:text-red-300">Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:block overflow-x-auto rounded-lg border border-slate-700">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-800 text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100" onClick={() => handleSort('name')}>
|
||||||
|
Job <SortIndicator field="name" />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold">Printer</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100" onClick={() => handleSort('started_at')}>
|
||||||
|
Started <SortIndicator field="started_at" />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick={() => handleSort('duration_seconds')}>
|
||||||
|
Duration <SortIndicator field="duration_seconds" />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick={() => handleSort('filament_grams_used')}>
|
||||||
|
Filament <SortIndicator field="filament_grams_used" />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick={() => handleSort('cost_usd')}>
|
||||||
|
Cost <SortIndicator field="cost_usd" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">No print jobs found.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((job: PrintJob) => (
|
||||||
|
<tr key={job.id} className="bg-slate-800/50 hover:bg-slate-700/50 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-100">{job.name}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{job.printer_name}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={job.status} /></td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{formatDateTime(job.started_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-slate-300">{formatDuration(job.duration_seconds)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-slate-300">{job.filament_grams_used != null ? `${job.filament_grams_used.toLocaleString()} g` : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-slate-300">{job.cost_usd != null ? `$${job.cost_usd.toFixed(2)}` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-500">No print jobs found.</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((job: PrintJob) => (
|
||||||
|
<div key={job.id} className="rounded-lg border border-slate-700 bg-slate-800 p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-slate-100">{job.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">{job.printer_name}</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs">Started</span>
|
||||||
|
<div className="text-slate-300">{formatDateTime(job.started_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs">Duration</span>
|
||||||
|
<div className="text-slate-300 tabular-nums">{formatDuration(job.duration_seconds)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs">Filament</span>
|
||||||
|
<div className="text-slate-300 tabular-nums">{job.filament_grams_used != null ? `${job.filament_grams_used.toLocaleString()} g` : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs">Cost</span>
|
||||||
|
<div className="text-slate-300 tabular-nums">{job.cost_usd != null ? `$${job.cost_usd.toFixed(2)}` : '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Showing {total === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-300 tabular-nums">{page + 1} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Save, Wifi, WifiOff, Sun, Moon, AlertCircle, CheckCircle2 } from 'lucide-react'
|
|
||||||
|
|
||||||
// TODO: Replace with API calls to GET/PUT /api/settings once Dex implements the endpoint
|
|
||||||
const DEFAULT_SETTINGS = {
|
|
||||||
lowStockThresholdGrams: 100,
|
|
||||||
filamentCrossSectionMm2: 2.405, // π × (0.875mm)² — standard 1.75mm filament
|
|
||||||
currency: 'USD',
|
|
||||||
theme: 'dark' as 'dark' | 'light' | 'system',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY']
|
|
||||||
|
|
||||||
type ConnectionStatus = 'idle' | 'testing' | 'ok' | 'error'
|
|
||||||
|
|
||||||
interface PrinterConfig {
|
|
||||||
name: string
|
|
||||||
type: 'bambu' | 'moonraker'
|
|
||||||
host: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Load from GET /api/printers once Dex implements the endpoint
|
|
||||||
const MOCK_PRINTERS: PrinterConfig[] = [
|
|
||||||
{ name: 'Bambu X1C #1', type: 'bambu', host: '192.168.1.100' },
|
|
||||||
{ name: 'Bambu X1C #2', type: 'bambu', host: '192.168.1.101' },
|
|
||||||
{ name: 'Voron 2.4 (Klipper)', type: 'moonraker', host: '192.168.1.200' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 py-4 border-b border-slate-700 last:border-0">
|
|
||||||
<div className="sm:w-64 shrink-0">
|
|
||||||
<div className="text-sm font-medium text-slate-200">{label}</div>
|
|
||||||
{hint && <div className="text-xs text-slate-500 mt-0.5">{hint}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionHeader({ title, description }: { title: string; description?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="text-base font-semibold text-slate-100">{title}</h3>
|
|
||||||
{description && <p className="text-sm text-slate-400 mt-0.5">{description}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const [lowStockThreshold, setLowStockThreshold] = useState(DEFAULT_SETTINGS.lowStockThresholdGrams)
|
|
||||||
const [crossSection, setCrossSection] = useState(DEFAULT_SETTINGS.filamentCrossSectionMm2)
|
|
||||||
const [currency, setCurrency] = useState(DEFAULT_SETTINGS.currency)
|
|
||||||
const [theme, setTheme] = useState(DEFAULT_SETTINGS.theme)
|
|
||||||
const [saved, setSaved] = useState(false)
|
|
||||||
const [saveError, setSaveError] = useState(false)
|
|
||||||
const [printerStatus, setPrinterStatus] = useState<Record<string, ConnectionStatus>>({})
|
|
||||||
|
|
||||||
const [thresholdError, setThresholdError] = useState('')
|
|
||||||
const [crossSectionError, setCrossSectionError] = useState('')
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
let valid = true
|
|
||||||
if (!lowStockThreshold || lowStockThreshold < 0 || lowStockThreshold > 10000) {
|
|
||||||
setThresholdError('Must be between 0 and 10,000 g')
|
|
||||||
valid = false
|
|
||||||
} else {
|
|
||||||
setThresholdError('')
|
|
||||||
}
|
|
||||||
if (!crossSection || crossSection <= 0 || crossSection > 100) {
|
|
||||||
setCrossSectionError('Must be a positive number (typical: 2.405 for 1.75mm)')
|
|
||||||
valid = false
|
|
||||||
} else {
|
|
||||||
setCrossSectionError('')
|
|
||||||
}
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate()) return
|
|
||||||
try {
|
|
||||||
// TODO: PUT /api/settings with { low_stock_threshold_grams, filament_cross_section_mm2, currency, theme }
|
|
||||||
setSaved(true)
|
|
||||||
setSaveError(false)
|
|
||||||
setTimeout(() => setSaved(false), 3000)
|
|
||||||
} catch {
|
|
||||||
setSaveError(true)
|
|
||||||
setSaved(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const testPrinterConnection = async (printer: PrinterConfig) => {
|
|
||||||
setPrinterStatus(s => ({ ...s, [printer.host]: 'testing' }))
|
|
||||||
// TODO: POST /api/printers/test with { host, type } — Dex to implement
|
|
||||||
await new Promise(r => setTimeout(r, 1500))
|
|
||||||
// Mock: hosts ending in .100 or .200 pass, others fail
|
|
||||||
const ok = printer.host.endsWith('.100') || printer.host.endsWith('.200')
|
|
||||||
setPrinterStatus(s => ({ ...s, [printer.host]: ok ? 'ok' : 'error' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-2xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-slate-100">Settings</h2>
|
|
||||||
<p className="text-sm text-slate-400">App configuration for Extrudex</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{saved && (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-emerald-400">
|
|
||||||
<CheckCircle2 size={16} /> Saved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{saveError && (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-red-400">
|
|
||||||
<AlertCircle size={16} /> Save failed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Save size={16} /> Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inventory Settings */}
|
|
||||||
<div className="rounded-lg border border-slate-700 bg-slate-800/50 px-4 pt-4 pb-2">
|
|
||||||
<SectionHeader
|
|
||||||
title="Inventory"
|
|
||||||
description="Controls how spools are tracked and displayed"
|
|
||||||
/>
|
|
||||||
<SettingRow
|
|
||||||
label="Low stock threshold"
|
|
||||||
hint="Spools below this weight are flagged as low stock"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={10000}
|
|
||||||
step={10}
|
|
||||||
value={lowStockThreshold}
|
|
||||||
onChange={e => setLowStockThreshold(Number(e.target.value))}
|
|
||||||
className={`w-32 rounded-lg bg-slate-800 border ${thresholdError ? 'border-red-500' : 'border-slate-700'} px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-400">grams</span>
|
|
||||||
</div>
|
|
||||||
{thresholdError && <p className="mt-1 text-xs text-red-400">{thresholdError}</p>}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
label="Filament cross-section area"
|
|
||||||
hint="Used to convert mm extruded → grams (π × r²)"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0.01}
|
|
||||||
max={100}
|
|
||||||
step={0.001}
|
|
||||||
value={crossSection}
|
|
||||||
onChange={e => setCrossSection(Number(e.target.value))}
|
|
||||||
className={`w-36 rounded-lg bg-slate-800 border ${crossSectionError ? 'border-red-500' : 'border-slate-700'} px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-400">mm²</span>
|
|
||||||
</div>
|
|
||||||
{crossSectionError && <p className="mt-1 text-xs text-red-400">{crossSectionError}</p>}
|
|
||||||
<p className="mt-1 text-xs text-slate-500">1.75mm filament = 2.405 mm² · 2.85mm filament = 6.379 mm²</p>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow label="Currency" hint="Used for cost display throughout the app">
|
|
||||||
<select
|
|
||||||
value={currency}
|
|
||||||
onChange={e => setCurrency(e.target.value)}
|
|
||||||
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
>
|
|
||||||
{CURRENCIES.map(c => (
|
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Appearance */}
|
|
||||||
<div className="rounded-lg border border-slate-700 bg-slate-800/50 px-4 pt-4 pb-2">
|
|
||||||
<SectionHeader title="Appearance" />
|
|
||||||
<SettingRow label="Theme" hint="Override system default color scheme">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{(['system', 'dark', 'light'] as const).map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setTheme(t)}
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium border transition-colors ${
|
|
||||||
theme === t
|
|
||||||
? 'bg-emerald-600 border-emerald-500 text-white'
|
|
||||||
: 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === 'dark' && <Moon size={14} />}
|
|
||||||
{t === 'light' && <Sun size={14} />}
|
|
||||||
{t === 'system' && <span className="text-xs">Auto</span>}
|
|
||||||
{t !== 'system' && <span className="capitalize">{t}</span>}
|
|
||||||
{t === 'system' && <span>System</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Printer Connections */}
|
|
||||||
<div className="rounded-lg border border-slate-700 bg-slate-800/50 px-4 pt-4 pb-2">
|
|
||||||
<SectionHeader
|
|
||||||
title="Printer Connections"
|
|
||||||
description="Test connectivity to configured printers"
|
|
||||||
/>
|
|
||||||
{/* TODO: Load printer list from GET /api/printers */}
|
|
||||||
{MOCK_PRINTERS.map(printer => {
|
|
||||||
const status = printerStatus[printer.host] ?? 'idle'
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
key={printer.host}
|
|
||||||
label={printer.name}
|
|
||||||
hint={`${printer.type === 'bambu' ? 'Bambu MQTT' : 'Moonraker'} · ${printer.host}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusBadge status={status} />
|
|
||||||
<button
|
|
||||||
onClick={() => testPrinterConnection(printer)}
|
|
||||||
disabled={status === 'testing'}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-slate-700 border border-slate-600 px-3 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{status === 'testing' ? (
|
|
||||||
<>
|
|
||||||
<span className="animate-spin inline-block w-3 h-3 border border-slate-400 border-t-transparent rounded-full" />
|
|
||||||
Testing…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Wifi size={12} /> Test Connection
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save footer (mobile convenience) */}
|
|
||||||
<div className="flex justify-end pb-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Save size={16} /> Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ConnectionStatus }) {
|
|
||||||
if (status === 'idle') return null
|
|
||||||
if (status === 'testing') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-700 border border-slate-600 px-2 py-0.5 text-xs text-slate-300">
|
|
||||||
Testing…
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (status === 'ok') {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-900/30 border border-emerald-700 px-2 py-0.5 text-xs text-emerald-300">
|
|
||||||
<CheckCircle2 size={11} /> Connected
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-900/30 border border-red-700 px-2 py-0.5 text-xs text-red-300">
|
|
||||||
<WifiOff size={11} /> Unreachable
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { PrintJob, ListResponse, PrintJobFilter } from '../types/printJob'
|
||||||
|
|
||||||
|
const API_BASE = '/api'
|
||||||
|
|
||||||
|
export async function fetchPrintJobs(filter: PrintJobFilter): Promise<ListResponse<PrintJob>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filter.status) params.set('status', filter.status)
|
||||||
|
if (filter.printer) params.set('printer', filter.printer)
|
||||||
|
if (filter.sort_by) params.set('sort_by', filter.sort_by)
|
||||||
|
if (filter.sort_dir) params.set('sort_dir', filter.sort_dir)
|
||||||
|
if (filter.limit !== undefined) params.set('limit', String(filter.limit))
|
||||||
|
if (filter.offset !== undefined) params.set('offset', String(filter.offset))
|
||||||
|
|
||||||
|
const res = await axios.get<ListResponse<PrintJob>>(`${API_BASE}/print-jobs?${params.toString()}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Print Job domain types
|
||||||
|
|
||||||
|
export type PrintJobStatus = 'pending' | 'printing' | 'completed' | 'failed'
|
||||||
|
|
||||||
|
export interface PrintJob {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
printer_name: string
|
||||||
|
status: PrintJobStatus
|
||||||
|
started_at: string
|
||||||
|
duration_seconds?: number
|
||||||
|
filament_grams_used?: number
|
||||||
|
cost_usd?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintJobFilter {
|
||||||
|
status?: PrintJobStatus
|
||||||
|
printer?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_dir?: 'asc' | 'desc'
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user