From 6fb5c47de6208ac9d56dcd52897c49ca9a6ca1a6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 00:36:26 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20Einzelst=C3=BCcke=20mit=20QR=20=E2=80=93?= =?UTF-8?q?=20anlegen,=20je-St=C3=BCck-Felder,=20Etiketten,=20/i/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gegenstands-Produkte haben jetzt den Umschalter "Menge je Lagerort / Einzelstücke". Bei Einzelstücken erscheint (volle Breite) eine Liste je Stueck mit eigener UID, QR-Code, Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz - direkt inline aenderbar, mit Entfernen (Grund) und druckbaren QR-Etiketten. Ein Scan oeffnet ueber die Route /i/ das passende Stueck. QR via qrcode (gebundelt, offline). Co-Authored-By: Claude Opus 4.8 --- web/package-lock.json | 306 +++++++++++++++++++++++++++ web/package.json | 1 + web/src/App.jsx | 2 + web/src/api.js | 8 + web/src/components/Einzelstuecke.jsx | 273 ++++++++++++++++++++++++ web/src/pages/ItemResolve.jsx | 37 ++++ web/src/pages/ProductForm.jsx | 47 +++- 7 files changed, 665 insertions(+), 9 deletions(-) create mode 100644 web/src/components/Einzelstuecke.jsx create mode 100644 web/src/pages/ItemResolve.jsx diff --git a/web/package-lock.json b/web/package-lock.json index 1b20f59..5b6cbb8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "vorrania-web", "version": "1.0.0", "dependencies": { + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-grid-layout": "^1.5.3", @@ -1270,6 +1271,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", @@ -1317,6 +1342,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -1338,6 +1372,17 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1347,6 +1392,24 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1372,6 +1435,21 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.395", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", @@ -1379,6 +1457,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1455,6 +1539,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1480,6 +1577,24 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1512,6 +1627,18 @@ "node": ">=6" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1579,6 +1706,51 @@ "node": ">=0.10.0" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1599,6 +1771,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", @@ -1639,6 +1820,23 @@ "react-is": "^16.13.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1758,6 +1956,21 @@ "react-dom": ">=16.8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -1828,6 +2041,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1838,6 +2057,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1961,12 +2206,73 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/web/package.json b/web/package.json index 728eba0..25b22f2 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-grid-layout": "^1.5.3", diff --git a/web/src/App.jsx b/web/src/App.jsx index c7fef98..06f4ac5 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -13,6 +13,7 @@ import CheckOut from "./pages/CheckOut"; import Groups from "./pages/Groups"; import Categories from "./pages/Categories"; import Shops from "./pages/Shops"; +import ItemResolve from "./pages/ItemResolve"; import Locations from "./pages/Locations"; import PackageTypes from "./pages/PackageTypes"; import Units from "./pages/Units"; @@ -134,6 +135,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 26d1ead..254885b 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -155,6 +155,14 @@ export const api = { }, deleteProductImage: (id) => request(`/products/${id}/image`, { method: "DELETE" }), + // Einzelstücke (Items mit UID/QR) + listItems: (productId) => request(`/products/${productId}/items`), + createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }), + itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`), + updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }), + removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }), + deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), + // Bestand checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }), diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx new file mode 100644 index 0000000..722ead9 --- /dev/null +++ b/web/src/components/Einzelstuecke.jsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import QRCode from "qrcode"; +import { api } from "../api"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import Icon from "./Icon"; +import { REASONS } from "./ObjektBestand"; + +const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v; + +/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */ +function QrImg({ text, size = 60 }) { + const [url, setUrl] = useState(null); + useEffect(() => { + let ok = true; + QRCode.toDataURL(text, { margin: 1, width: size * 2 }) + .then((u) => { if (ok) setUrl(u); }) + .catch(() => {}); + return () => { ok = false; }; + }, [text, size]); + return url + ? QR + : ; +} + +const EMPTY_ADD = { + count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "", +}; + +/** + * Einzelstücke eines Gegenstands: jedes physische Stück mit eigener UID/QR und + * eigenen Angaben (Lagerort, gekauft am, Garantie, gekauft bei, Notiz). + */ +export default function Einzelstuecke({ product, locations, shops, isAdmin, onChanged, onError }) { + const confirm = useConfirm(); + const toast = useToast(); + const [items, setItems] = useState([]); + const [addForm, setAddForm] = useState(EMPTY_ADD); + const [remove, setRemove] = useState(null); // { item, reason, note } | null + const origin = window.location.origin; + + async function load() { + try { + setItems(await api.listItems(product.id)); + } catch (err) { onError(err.message); } + } + useEffect(() => { load(); /* eslint-disable-next-line */ }, [product.id]); + + async function nachAktion() { + await load(); + if (onChanged) await onChanged(); + } + + async function add(e) { + e.preventDefault(); + try { + await api.createItems(product.id, { + count: Number(addForm.count) || 1, + location_id: addForm.location_id === "" ? null : Number(addForm.location_id), + shop_id: addForm.shop_id === "" ? null : Number(addForm.shop_id), + acquired_on: addForm.acquired_on || null, + warranty_until: addForm.warranty_until || null, + note: addForm.note || null, + }); + setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id }); + await nachAktion(); + toast("Einzelstück(e) angelegt."); + } catch (err) { onError(err.message); } + } + + async function patchItem(item, body) { + try { + await api.updateItem(item.id, body); + await load(); + } catch (err) { onError(err.message); } + } + + async function doRemove(e) { + e.preventDefault(); + try { + await api.removeItem(remove.item.id, { reason: remove.reason, note: remove.note || null }); + setRemove(null); + await nachAktion(); + toast("Einzelstück entfernt."); + } catch (err) { onError(err.message); } + } + + async function loeschen(item) { + const ok = await confirm({ + title: `Einzelstück ${item.uid} löschen?`, + message: "Ohne Grund – nur als Korrektur. Für „kaputt/verloren“ bitte „Entfernen“.", + confirmLabel: "Löschen", danger: true, + }); + if (!ok) return; + try { + await api.deleteItem(item.id); + await nachAktion(); + } catch (err) { onError(err.message); } + } + + async function druckeEtiketten() { + const labels = await Promise.all(items.map(async (it) => { + const url = await QRCode.toDataURL(`${origin}/i/${it.uid}`, { margin: 1, width: 260 }); + const name = (product.name || "").replace(/[<>&]/g, ""); + return `
${it.uid}
${name}
`; + })); + const w = window.open("", "_blank"); + if (!w) { onError("Bitte Pop-ups für den Druck erlauben."); return; } + w.document.write( + `Etiketten${labels.join("")} + `, + ); + w.document.close(); + } + + const ortName = (id) => (id == null ? "" : locations.find((l) => l.id === id)?.name || ""); + + return ( +
+
+

Einzelstücke

+ {items.length} Stück + {items.length > 0 && ( + + )} +
+ +
+ + + + + + + + + {items.map((it) => ( + + + + + + + + + + + ))} + {items.length === 0 && } + +
QRUIDLagerortGekauft amGarantie bisGekauft beiNotiz
{it.uid} + {isAdmin ? ( + + ) : {ortName(it.location_id) || "–"}} + + {isAdmin ? ( + { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} /> + ) : {it.acquired_on || "–"}} + + {isAdmin ? ( + { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} /> + ) : {it.warranty_until || "–"}} + + {isAdmin ? ( + + ) : {it.shop_name || "–"}} + + {isAdmin ? ( + { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} /> + ) : {it.note || "–"}} + + {isAdmin && ( +
+ + +
+ )} +
Noch keine Einzelstücke.
+
+ + {isAdmin && ( +
+

Einzelstücke anlegen

+
+ + + +
+
+ + + +
+

+ Gemeinsame Startwerte für alle neuen Stücke – danach je Stück änderbar + (z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR. +

+ +
+ )} + + {isAdmin && remove && ( +
+

Einzelstück {remove.item.uid} entfernen

+ +
+
+ + +
+ +
+ )} +
+ ); +} diff --git a/web/src/pages/ItemResolve.jsx b/web/src/pages/ItemResolve.jsx new file mode 100644 index 0000000..749952b --- /dev/null +++ b/web/src/pages/ItemResolve.jsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { api } from "../api"; +import Icon from "../components/Icon"; + +/** + * Ziel eines gescannten QR-Codes (/i/): löst die UID auf und öffnet den + * zugehörigen Artikel mit dem Einzelstück im Blick. + */ +export default function ItemResolve() { + const { uid } = useParams(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + useEffect(() => { + let ok = true; + api.itemByUid(uid) + .then((item) => { + if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`, + { replace: true }); + }) + .catch((err) => { if (ok) setError(err.message); }); + return () => { ok = false; }; + }, [uid, navigate]); + + return ( +
+
+
+

Einzelstück {uid}

+
{error ? error : "Wird geöffnet…"}
+
+
+ {error &&
{error}
} +
+ ); +} diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 493244b..f97e5dd 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -12,6 +12,7 @@ import { useSettings } from "../settings"; import { asTree } from "../categoryTree"; import { DynamicFields, FIELD_TYPES } from "../fields"; import ObjektBestand from "../components/ObjektBestand"; +import Einzelstuecke from "../components/Einzelstuecke"; import { daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, toMonthInput, unitShort, @@ -20,7 +21,7 @@ import { const EMPTY = { barcode: "", name: "", brand: "", image_url: "", unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", - group_id: "", category_id: "", shop_id: "", product_url: "", + group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, }; // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) @@ -384,6 +385,7 @@ export default function ProductForm() { category_id: p.category_id ?? "", shop_id: p.shop_id ?? "", product_url: p.product_url || "", + individual: Boolean(p.individual), }); setFieldValues(p.field_values || {}); setModus(p.tracking === "object" ? "object" : "food"); @@ -440,10 +442,11 @@ export default function ProductForm() { Object.entries(fieldValues).forEach(([k, v]) => { if (erlaubt.has(String(k))) fv[k] = v; }); - return { ...base, field_values: fv }; + return { ...base, individual: form.individual, field_values: fv }; } return { ...base, + individual: false, unit_id: form.unit_id === "" ? null : Number(form.unit_id), package_size: form.package_size === "" ? null : Number(form.package_size), package_label: form.package_label.trim() || null, @@ -740,13 +743,27 @@ export default function ProductForm() { {isObject && (<>
+
+
+
+ {!form.individual && ( + + )}