Web: Einzelstücke mit QR – anlegen, je-Stück-Felder, Etiketten, /i/<uid>
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/<uid> das passende Stueck. QR via qrcode (gebundelt, offline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
306
web/package-lock.json
generated
306
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/products" element={<Protected><Products /></Protected>} />
|
||||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||||
<Route path="/groups" element={<Protected><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
273
web/src/components/Einzelstuecke.jsx
Normal file
273
web/src/components/Einzelstuecke.jsx
Normal file
@@ -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
|
||||
? <img src={url} width={size} height={size} alt="QR" style={{ display: "block" }} />
|
||||
: <span style={{ display: "inline-block", width: size, height: size }} />;
|
||||
}
|
||||
|
||||
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 `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
|
||||
}));
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { onError("Bitte Pop-ups für den Druck erlauben."); return; }
|
||||
w.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>Etiketten</title><style>
|
||||
body{font-family:sans-serif;margin:8mm;display:flex;flex-wrap:wrap;gap:6mm}
|
||||
.label{width:34mm;text-align:center;border:1px solid #ccc;border-radius:3mm;padding:3mm;page-break-inside:avoid}
|
||||
.label img{width:28mm;height:28mm}
|
||||
.uid{font-weight:700;font-size:11pt;letter-spacing:1px;margin-top:1mm}
|
||||
.name{font-size:8pt;color:#444}
|
||||
</style></head><body>${labels.join("")}
|
||||
<script>window.onload=function(){window.print()}</script></body></html>`,
|
||||
);
|
||||
w.document.close();
|
||||
}
|
||||
|
||||
const ortName = (id) => (id == null ? "" : locations.find((l) => l.id === id)?.name || "");
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="tag" /><h2>Einzelstücke</h2>
|
||||
<span className="muted small">{items.length} Stück</span>
|
||||
{items.length > 0 && (
|
||||
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
|
||||
onClick={druckeEtiketten}>
|
||||
<Icon name="download" size={15} />Etiketten drucken
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th>
|
||||
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
|
||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||
<td data-label="Lagerort">
|
||||
{isAdmin ? (
|
||||
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
||||
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– ohne –</option>
|
||||
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Gekauft am">
|
||||
{isAdmin ? (
|
||||
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
|
||||
) : <span>{it.acquired_on || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Garantie bis">
|
||||
{isAdmin ? (
|
||||
<input type="date" defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
|
||||
) : <span>{it.warranty_until || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Gekauft bei">
|
||||
{isAdmin ? (
|
||||
<select value={it.shop_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
||||
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– unbekannt –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
) : <span>{it.shop_name || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Notiz">
|
||||
{isAdmin ? (
|
||||
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" style={{ marginTop: 0, minWidth: 120 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
|
||||
) : <span>{it.note || "–"}</span>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn-icon" title="Mit Grund entfernen"
|
||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||
<Icon name="checkout" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon danger" title="Löschen (Korrektur)"
|
||||
onClick={() => loeschen(it)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={add} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="card-head"><Icon name="plus" /><h3>Einzelstücke anlegen</h3></div>
|
||||
<div className="row">
|
||||
<label style={{ width: 90 }}>Anzahl
|
||||
<input type="number" min="1" max="200" value={addForm.count}
|
||||
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Lagerort
|
||||
<select value={addForm.location_id}
|
||||
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
|
||||
<option value="">– ohne –</option>
|
||||
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">Gekauft bei
|
||||
<select value={addForm.shop_id}
|
||||
onChange={(e) => setAddForm({ ...addForm, shop_id: e.target.value })}>
|
||||
<option value="">– unbekannt –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">Gekauft am
|
||||
<input type="date" value={addForm.acquired_on}
|
||||
onChange={(e) => setAddForm({ ...addForm, acquired_on: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Garantie bis
|
||||
<input type="date" value={addForm.warranty_until}
|
||||
onChange={(e) => setAddForm({ ...addForm, warranty_until: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Notiz
|
||||
<input value={addForm.note} placeholder="optional"
|
||||
onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
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.
|
||||
</p>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isAdmin && remove && (
|
||||
<form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="card-head"><Icon name="trash" /><h3>Einzelstück {remove.item.uid} entfernen</h3>
|
||||
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
|
||||
onClick={() => setRemove(null)}><Icon name="close" size={16} /></button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">Grund
|
||||
<select value={remove.reason} onChange={(e) => setRemove({ ...remove, reason: e.target.value })}>
|
||||
{REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">Notiz (optional)
|
||||
<input value={remove.note} onChange={(e) => setRemove({ ...remove, note: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn danger">Entfernen</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
web/src/pages/ItemResolve.jsx
Normal file
37
web/src/pages/ItemResolve.jsx
Normal file
@@ -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/<uid>): 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 (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einzelstück {uid}</h1>
|
||||
<div className="sub">{error ? error : "Wird geöffnet…"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (<>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Gekauft bei
|
||||
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– unbekannt / mehrere –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
Verwaltung
|
||||
<div className="segmented">
|
||||
<button type="button" className={!form.individual ? "active" : ""}
|
||||
onClick={() => set("individual", false)} disabled={readOnly}>Menge je Lagerort</button>
|
||||
<button type="button" className={form.individual ? "active" : ""}
|
||||
onClick={() => set("individual", true)} disabled={readOnly}>Einzelstücke</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="grow" />
|
||||
</div>
|
||||
<div className="row">
|
||||
{!form.individual && (
|
||||
<label className="grow">
|
||||
Gekauft bei
|
||||
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– unbekannt / mehrere –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grow">
|
||||
Produktlink
|
||||
<div className="field-inline">
|
||||
@@ -831,7 +848,7 @@ export default function ProductForm() {
|
||||
</form>
|
||||
|
||||
{!isNew && (isObject ? (
|
||||
product && (
|
||||
product && !form.individual && (
|
||||
<ObjektBestand
|
||||
product={product}
|
||||
lots={lots}
|
||||
@@ -860,6 +877,18 @@ export default function ProductForm() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Einzelstücke über die volle Breite – die Tabelle hat viele Spalten. */}
|
||||
{!isNew && product && isObject && form.individual && (
|
||||
<Einzelstuecke
|
||||
product={product}
|
||||
locations={locations}
|
||||
shops={shops}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={async () => { setProduct(await api.getProduct(id)); }}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen
|
||||
Formular und Chargen nicht auseinanderschieben. */}
|
||||
{!isNew && product && (
|
||||
|
||||
Reference in New Issue
Block a user