kuchen diagram update
This commit is contained in:
@@ -6,6 +6,13 @@ VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
|||||||
VITE_APPWRITE_PROJECT_ID=dein-projekt-id
|
VITE_APPWRITE_PROJECT_ID=dein-projekt-id
|
||||||
VITE_APPWRITE_DATABASE_ID=defekttrack_db
|
VITE_APPWRITE_DATABASE_ID=defekttrack_db
|
||||||
|
|
||||||
|
# Optional: Storage-Bucket-ID für Kommentar-Anhänge (Standard: defekttrack_anhaenge)
|
||||||
|
# VITE_APPWRITE_ATTACHMENTS_BUCKET_ID=defekttrack_anhaenge
|
||||||
|
|
||||||
|
# Nur Dev: Wenn VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1 — wohin Vite /v1 weiterleitet (Origin ohne /v1)
|
||||||
|
# Standard im Repo: https://appwrite.webklar.com — bei eigenem Server hier die Basis-URL eintragen.
|
||||||
|
# VITE_APPWRITE_PROXY_TARGET=https://appwrite.webklar.com
|
||||||
|
|
||||||
# Nur für das Setup-Skript (npm run setup) und API-Server – nicht im Frontend
|
# Nur für das Setup-Skript (npm run setup) und API-Server – nicht im Frontend
|
||||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
APPWRITE_API_KEY=dein-api-key-hier
|
APPWRITE_API_KEY=dein-api-key-hier
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -100,6 +100,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1617,6 +1618,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -2362,6 +2364,7 @@
|
|||||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2427,6 +2430,7 @@
|
|||||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2825,6 +2829,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3943,6 +3948,7 @@
|
|||||||
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
|
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4226,6 +4232,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -4914,6 +4921,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -6845,6 +6853,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7085,6 +7094,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7094,6 +7104,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.25.0"
|
"scheduler": "^0.25.0"
|
||||||
},
|
},
|
||||||
@@ -7217,6 +7228,7 @@
|
|||||||
"version": "2.15.4",
|
"version": "2.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"eventemitter3": "^4.0.1",
|
"eventemitter3": "^4.0.1",
|
||||||
@@ -8500,6 +8512,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz",
|
||||||
"integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==",
|
"integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.24.2",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
@@ -8890,6 +8903,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"setup": "node scripts/setup-appwrite.js",
|
"setup": "node scripts/setup-appwrite.js",
|
||||||
|
"setup:storage": "node scripts/create-attachments-bucket.js",
|
||||||
"seed:dummy": "node scripts/seed-dummy-data.js"
|
"seed:dummy": "node scripts/seed-dummy-data.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
63
scripts/create-attachments-bucket.js
Normal file
63
scripts/create-attachments-bucket.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Legt nur den Storage-Bucket für Kommentar-Anhänge an (ohne komplettes Setup).
|
||||||
|
* Benötigt .env mit APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID, APPWRITE_API_KEY.
|
||||||
|
*/
|
||||||
|
import { Client, Storage, Permission, Role } from 'node-appwrite';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = resolve(__dirname, '..', '.env');
|
||||||
|
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIdx).trim();
|
||||||
|
const value = trimmed.slice(eqIdx + 1).trim();
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const ENDPOINT = process.env.APPWRITE_ENDPOINT;
|
||||||
|
const PROJECT_ID = process.env.VITE_APPWRITE_PROJECT_ID;
|
||||||
|
const API_KEY = process.env.APPWRITE_API_KEY;
|
||||||
|
const BUCKET_ID = process.env.VITE_APPWRITE_ATTACHMENTS_BUCKET_ID || 'defekttrack_anhaenge';
|
||||||
|
|
||||||
|
if (!ENDPOINT || !PROJECT_ID || !API_KEY) {
|
||||||
|
console.error('Bitte APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID und APPWRITE_API_KEY in .env setzen.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client().setEndpoint(ENDPOINT).setProject(PROJECT_ID).setKey(API_KEY);
|
||||||
|
const storageService = new Storage(client);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await storageService.createBucket({
|
||||||
|
bucketId: BUCKET_ID,
|
||||||
|
name: 'DefektTrack Kommentar-Anhänge',
|
||||||
|
permissions: [Permission.read(Role.users()), Permission.create(Role.users())],
|
||||||
|
fileSecurity: false,
|
||||||
|
enabled: true,
|
||||||
|
maximumFileSize: 15 * 1024 * 1024,
|
||||||
|
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'],
|
||||||
|
});
|
||||||
|
console.log(`Bucket erstellt: ${BUCKET_ID}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
console.log(`Bucket existiert bereits: ${BUCKET_ID}`);
|
||||||
|
} else {
|
||||||
|
console.error('Fehler:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client, Databases, Teams, Users, ID, Permission, Role, Query } from 'node-appwrite';
|
import { Client, Databases, Teams, Users, ID, Permission, Role, Query, Storage } from 'node-appwrite';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { resolve, dirname } from 'path';
|
import { resolve, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -39,6 +39,9 @@ const client = new Client()
|
|||||||
const databases = new Databases(client);
|
const databases = new Databases(client);
|
||||||
const teamsService = new Teams(client);
|
const teamsService = new Teams(client);
|
||||||
const users = new Users(client);
|
const users = new Users(client);
|
||||||
|
const storageService = new Storage(client);
|
||||||
|
|
||||||
|
const ATTACHMENTS_BUCKET_ID = 'defekttrack_anhaenge';
|
||||||
|
|
||||||
const TEAM_ROLES = ['admin', 'firmenleiter', 'filialleiter', 'service', 'lager'];
|
const TEAM_ROLES = ['admin', 'firmenleiter', 'filialleiter', 'service', 'lager'];
|
||||||
|
|
||||||
@@ -180,7 +183,7 @@ async function createAssetsCollection() {
|
|||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'zustaendig', 128, true);
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'zustaendig', 128, true);
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'status', 32, true);
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'status', 32, true);
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'prio', 16, true);
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'prio', 16, true);
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'kommentar', 2048, false, '');
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'kommentar', 8192, false, '');
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'createdBy', 128, false, '');
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'createdBy', 128, false, '');
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'lastEditedBy', 128, false, '');
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'lastEditedBy', 128, false, '');
|
||||||
console.log(' Attribute fuer assets erstellt (erlNummer, seriennummer, artikelNr, bezeichnung, defekt, lagerstandortId, zustaendig, status, prio, kommentar, createdBy, lastEditedBy)');
|
console.log(' Attribute fuer assets erstellt (erlNummer, seriennummer, artikelNr, bezeichnung, defekt, lagerstandortId, zustaendig, status, prio, kommentar, createdBy, lastEditedBy)');
|
||||||
@@ -234,6 +237,27 @@ async function createAuditLogsCollection() {
|
|||||||
console.log(' Attribute fuer audit_logs erstellt (assetId, action, details, userId, userName)');
|
console.log(' Attribute fuer audit_logs erstellt (assetId, action, details, userId, userName)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAttachmentsBucket() {
|
||||||
|
try {
|
||||||
|
await storageService.createBucket({
|
||||||
|
bucketId: ATTACHMENTS_BUCKET_ID,
|
||||||
|
name: 'DefektTrack Kommentar-Anhänge',
|
||||||
|
permissions: [Permission.read(Role.users()), Permission.create(Role.users())],
|
||||||
|
fileSecurity: false,
|
||||||
|
enabled: true,
|
||||||
|
maximumFileSize: 15 * 1024 * 1024,
|
||||||
|
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'],
|
||||||
|
});
|
||||||
|
console.log(`Storage-Bucket erstellt: ${ATTACHMENTS_BUCKET_ID}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 409) {
|
||||||
|
console.log(`Storage-Bucket existiert bereits: ${ATTACHMENTS_BUCKET_ID}`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createTeams() {
|
async function createTeams() {
|
||||||
for (const role of TEAM_ROLES) {
|
for (const role of TEAM_ROLES) {
|
||||||
try {
|
try {
|
||||||
@@ -330,6 +354,9 @@ async function main() {
|
|||||||
await createDatabase();
|
await createDatabase();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
await createAttachmentsBucket();
|
||||||
|
console.log('');
|
||||||
|
|
||||||
await createLocationsCollection();
|
await createLocationsCollection();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -361,6 +388,9 @@ async function main() {
|
|||||||
console.log(' Passwort: Admin1234!');
|
console.log(' Passwort: Admin1234!');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Vergiss nicht, den API-Key aus .env zu entfernen oder sicher aufzubewahren.');
|
console.log('Vergiss nicht, den API-Key aus .env zu entfernen oder sicher aufzubewahren.');
|
||||||
|
console.log('');
|
||||||
|
console.log(`Kommentar-Anhänge: Bucket „${ATTACHMENTS_BUCKET_ID}“ — optional in .env:`);
|
||||||
|
console.log(` VITE_APPWRITE_ATTACHMENTS_BUCKET_ID=${ATTACHMENTS_BUCKET_ID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ArrowLeft, Pencil, Save, X } from 'lucide-react';
|
import { ArrowLeft, Pencil, Save, X } from 'lucide-react';
|
||||||
|
import { parseKommentarForDisplay } from '@/lib/kommentarAnhaenge';
|
||||||
|
import KommentarAnhaengeList from '@/components/KommentarAnhaengeList';
|
||||||
|
|
||||||
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
|
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
|
||||||
const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
|
const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
|
||||||
@@ -47,6 +49,25 @@ function PrioBadge({ prio }) {
|
|||||||
return <Badge className="border-green-300 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">{PRIO_LABELS[prio]}</Badge>;
|
return <Badge className="border-green-300 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">{PRIO_LABELS[prio]}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KommentarReadonly({ value }) {
|
||||||
|
const { subject, body, attachments } = parseKommentarForDisplay(value);
|
||||||
|
const empty = !subject && !(body && body.trim()) && attachments.length === 0;
|
||||||
|
if (empty) return <p className="text-sm">–</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{subject && (
|
||||||
|
<div className="rounded-md bg-amber-100 px-3 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{subject}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{body && body.trim() ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{body}</p>
|
||||||
|
) : null}
|
||||||
|
<KommentarAnhaengeList attachments={attachments} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AssetDetail() {
|
export default function AssetDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -348,7 +369,18 @@ export default function AssetDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PropertyField label="Kommentar" value={form.kommentar} editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label>Kommentar</Label>
|
||||||
|
{editing ? (
|
||||||
|
<Textarea
|
||||||
|
value={form.kommentar}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, kommentar: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KommentarReadonly value={form.kommentar} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -7,16 +7,11 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { parseKommentarForDisplay } from '@/lib/kommentarAnhaenge';
|
||||||
|
import KommentarAnhaengeList from '@/components/KommentarAnhaengeList';
|
||||||
|
|
||||||
export default function CommentPopup({ artikel, onClose }) {
|
export default function CommentPopup({ artikel, onClose }) {
|
||||||
let subject = '';
|
const { subject, body, attachments } = parseKommentarForDisplay(artikel?.kommentar);
|
||||||
let text = artikel.kommentar;
|
|
||||||
|
|
||||||
const match = artikel.kommentar.match(/^\*([^*]+)\*/);
|
|
||||||
if (match) {
|
|
||||||
subject = match[1].trim();
|
|
||||||
text = artikel.kommentar.substring(match[0].length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
@@ -35,8 +30,9 @@ export default function CommentPopup({ artikel, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-md bg-muted px-3 py-2 text-sm whitespace-pre-wrap">
|
<div className="rounded-md bg-muted px-3 py-2 text-sm whitespace-pre-wrap">
|
||||||
{text || '(Kein weiterer Kommentar)'}
|
{body || (attachments.length === 0 ? '(Kein weiterer Kommentar)' : '')}
|
||||||
</div>
|
</div>
|
||||||
|
<KommentarAnhaengeList attachments={attachments} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
|
||||||
|
import { ID, Permission, Role } from 'appwrite';
|
||||||
|
import { appendAttachmentMarkers } from '@/lib/kommentarAnhaenge';
|
||||||
|
import { Upload, X, FileIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
erlNummer: '',
|
erlNummer: '',
|
||||||
@@ -25,10 +30,24 @@ const EMPTY_FORM = {
|
|||||||
kommentar: '',
|
kommentar: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALLOWED_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']);
|
||||||
|
const MAX_FILE_BYTES = 15 * 1024 * 1024;
|
||||||
|
const KOMMENTAR_MAX = 8192;
|
||||||
|
|
||||||
|
function extOk(name) {
|
||||||
|
const i = name.lastIndexOf('.');
|
||||||
|
if (i < 0) return false;
|
||||||
|
return ALLOWED_EXT.has(name.slice(i + 1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
export default function DefektForm({ onAdd, showToast, lagerstandorte, colleagues }) {
|
export default function DefektForm({ onAdd, showToast, lagerstandorte, colleagues }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const ownName = user?.name || user?.email || '';
|
const ownName = user?.name || user?.email || '';
|
||||||
const [form, setForm] = useState({ ...EMPTY_FORM, zustaendig: ownName });
|
const [form, setForm] = useState({ ...EMPTY_FORM, zustaendig: ownName });
|
||||||
|
const [attachments, setAttachments] = useState([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ownName && !form.zustaendig) {
|
if (ownName && !form.zustaendig) {
|
||||||
@@ -45,6 +64,60 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
|||||||
setForm((prev) => ({ ...prev, [name]: value }));
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadFiles = useCallback(
|
||||||
|
async (fileList) => {
|
||||||
|
if (!ATTACHMENTS_BUCKET_ID) {
|
||||||
|
showToast('Anhänge: Appwrite-Bucket fehlt (VITE_APPWRITE_ATTACHMENTS_BUCKET_ID).', '#C62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = Array.from(fileList || []).filter(Boolean);
|
||||||
|
if (!arr.length) return;
|
||||||
|
|
||||||
|
for (const file of arr) {
|
||||||
|
if (!extOk(file.name)) {
|
||||||
|
showToast(`Dateityp nicht erlaubt: ${file.name} (nur JPG, PNG, GIF, WebP, PDF)`, '#C62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
|
showToast(`Datei zu groß (max. 15 MB): ${file.name}`, '#C62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of arr) {
|
||||||
|
const res = await storage.createFile(ATTACHMENTS_BUCKET_ID, ID.unique(), file, [
|
||||||
|
Permission.read(Role.users()),
|
||||||
|
]);
|
||||||
|
setAttachments((prev) => [...prev, { fileId: res.$id, name: file.name }]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const code = err?.code;
|
||||||
|
const is404 = code === 404 || code === '404' || String(err?.message || '').includes('404');
|
||||||
|
const hint404 =
|
||||||
|
`Storage-Bucket „${ATTACHMENTS_BUCKET_ID}“ existiert vermutlich nicht, oder der Vite-Proxy (/v1) zeigt nicht auf deinen Appwrite-Server. ` +
|
||||||
|
`Lösung: In der Appwrite-Konsole unter „Storage“ einen Bucket mit dieser ID anlegen, oder „npm run setup:storage“ ausführen. ` +
|
||||||
|
`Wenn dein Appwrite woanders läuft: in .env „VITE_APPWRITE_PROXY_TARGET“ auf die Origin setzen (ohne /v1).`;
|
||||||
|
const msg = is404 ? hint404 : (err.message || String(err));
|
||||||
|
showToast('Upload fehlgeschlagen: ' + msg, '#C62828');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
function removeAttachment(fileId) {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.fileId !== fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
uploadFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -53,6 +126,12 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const built = appendAttachmentMarkers(form.kommentar.trim(), attachments);
|
||||||
|
if (built.length > KOMMENTAR_MAX) {
|
||||||
|
showToast(`Kommentar inkl. Anhänge zu lang (max. ${KOMMENTAR_MAX} Zeichen).`, '#C62828');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAdd({
|
await onAdd({
|
||||||
erlNummer: form.erlNummer.trim(),
|
erlNummer: form.erlNummer.trim(),
|
||||||
@@ -63,10 +142,11 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
|||||||
lagerstandortId: form.lagerstandortId,
|
lagerstandortId: form.lagerstandortId,
|
||||||
zustaendig: form.zustaendig.trim(),
|
zustaendig: form.zustaendig.trim(),
|
||||||
prio: form.prio,
|
prio: form.prio,
|
||||||
kommentar: form.kommentar.trim(),
|
kommentar: built,
|
||||||
});
|
});
|
||||||
showToast('Asset erfasst: ' + form.erlNummer.trim());
|
showToast('Asset erfasst: ' + form.erlNummer.trim());
|
||||||
setForm({ ...EMPTY_FORM, zustaendig: ownName });
|
setForm({ ...EMPTY_FORM, zustaendig: ownName });
|
||||||
|
setAttachments([]);
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Fehler beim Speichern!', '#C62828');
|
showToast('Fehler beim Speichern!', '#C62828');
|
||||||
}
|
}
|
||||||
@@ -192,7 +272,84 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700">
|
<div className="space-y-2">
|
||||||
|
<Label>Anhang (optional)</Label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||||
|
multiple
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(ev) => {
|
||||||
|
uploadFiles(ev.target.files);
|
||||||
|
ev.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false);
|
||||||
|
}}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[100px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors',
|
||||||
|
dragOver ? 'border-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : 'border-muted-foreground/25 bg-muted/20 hover:bg-muted/35'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" aria-hidden />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Datei hierher ziehen oder Fläche anklicken
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
JPG, PNG, GIF, WebP oder PDF · max. 15 MB pro Datei
|
||||||
|
</p>
|
||||||
|
{uploading && <p className="text-xs font-medium text-amber-700 dark:text-amber-400">Wird hochgeladen…</p>}
|
||||||
|
</div>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<ul className="flex flex-col gap-2 pt-1">
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.fileId}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{a.name}</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => removeAttachment(a.fileId)}
|
||||||
|
aria-label="Anhang entfernen"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700" disabled={uploading}>
|
||||||
Ware erfassen
|
Ware erfassen
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
||||||
|
import { hasInfoContent } from '@/lib/kommentarAnhaenge';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import CommentPopup from './CommentPopup';
|
import CommentPopup from './CommentPopup';
|
||||||
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
||||||
@@ -54,6 +55,10 @@ function resolveStandortName(asset, lagerstandorte) {
|
|||||||
|
|
||||||
const STATUS_FILTER_MAP = { offen: 'offen', bearbeitung: 'in_bearbeitung', entsorgt: 'entsorgt' };
|
const STATUS_FILTER_MAP = { offen: 'offen', bearbeitung: 'in_bearbeitung', entsorgt: 'entsorgt' };
|
||||||
|
|
||||||
|
/** Rahmen um jede Asset-Zeile (funktioniert mit border-separate / border-spacing) */
|
||||||
|
const ENTITY_ROW_FRAME =
|
||||||
|
'[&>td]:border-t [&>td]:border-b [&>td]:border-border [&>td:first-child]:border-l [&>td:first-child]:rounded-l-md [&>td:last-child]:border-r [&>td:last-child]:rounded-r-md';
|
||||||
|
|
||||||
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte, statusFilter }) {
|
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte, statusFilter }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -202,7 +207,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<Table className="border-separate border-spacing-y-2">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
|
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
|
||||||
@@ -242,7 +247,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
const statusBtnCfg = STATUS_BUTTON_CONFIG[a.status] || STATUS_BUTTON_CONFIG.offen;
|
const statusBtnCfg = STATUS_BUTTON_CONFIG[a.status] || STATUS_BUTTON_CONFIG.offen;
|
||||||
|
|
||||||
const rowClassName = overdue
|
const rowClassName = overdue
|
||||||
? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20'
|
? `bg-amber-50/50 dark:bg-amber-950/20 [&>td:first-child]:!border-l-2 [&>td:first-child]:!border-l-amber-500`
|
||||||
: index % 2 === 0
|
: index % 2 === 0
|
||||||
? 'bg-muted/50'
|
? 'bg-muted/50'
|
||||||
: '';
|
: '';
|
||||||
@@ -250,7 +255,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={a.$id}
|
key={a.$id}
|
||||||
className={rowClassName}
|
className={[ENTITY_ROW_FRAME, rowClassName, 'border-b-0'].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
<TableCell className={`font-semibold p-2 ${PRIO_ERL_CELL[a.prio] || 'bg-muted/50 text-foreground'}`}>
|
<TableCell className={`font-semibold p-2 ${PRIO_ERL_CELL[a.prio] || 'bg-muted/50 text-foreground'}`}>
|
||||||
{a.erlNummer || '–'}
|
{a.erlNummer || '–'}
|
||||||
@@ -301,9 +306,9 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!a.kommentar}
|
disabled={!hasInfoContent(a.kommentar)}
|
||||||
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
|
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
|
||||||
onClick={() => a.kommentar && setCommentAsset(a)}
|
onClick={() => hasInfoContent(a.kommentar) && setCommentAsset(a)}
|
||||||
>
|
>
|
||||||
Info
|
Info
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Sector, Label } from 'recharts';
|
||||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||||
import { Query } from 'appwrite';
|
import { Query } from 'appwrite';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
@@ -80,7 +80,7 @@ export default function FilialleiterDashboard() {
|
|||||||
const locationId = userMeta?.locationId || '';
|
const locationId = userMeta?.locationId || '';
|
||||||
|
|
||||||
const [ownAssets, setOwnAssets] = useState([]);
|
const [ownAssets, setOwnAssets] = useState([]);
|
||||||
const [allAssetsTotal, setAllAssetsTotal] = useState(0);
|
const [allAssets, setAllAssets] = useState([]);
|
||||||
const [allLocationsCount, setAllLocationsCount] = useState(1);
|
const [allLocationsCount, setAllLocationsCount] = useState(1);
|
||||||
const [colleagues, setColleagues] = useState([]);
|
const [colleagues, setColleagues] = useState([]);
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ export default function FilialleiterDashboard() {
|
|||||||
const lagerIds = new Set(lagerRes.documents.map((l) => l.$id));
|
const lagerIds = new Set(lagerRes.documents.map((l) => l.$id));
|
||||||
const assetsForLocation = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId));
|
const assetsForLocation = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId));
|
||||||
setOwnAssets(assetsForLocation);
|
setOwnAssets(assetsForLocation);
|
||||||
setAllAssetsTotal(assetsRes.total);
|
setAllAssets(assetsRes.documents);
|
||||||
setAllLocationsCount(Math.max(locsRes.total, 1));
|
setAllLocationsCount(Math.max(locsRes.total, 1));
|
||||||
setColleagues(metaRes.documents.filter((d) => d.userName));
|
setColleagues(metaRes.documents.filter((d) => d.userName));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -175,7 +175,78 @@ export default function FilialleiterDashboard() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssetsTotal / allLocationsCount) : 0;
|
const pieChartConfig = {
|
||||||
|
value: { label: 'Anzahl' },
|
||||||
|
offen: { label: 'Offen', color: '#60a5fa' },
|
||||||
|
inBearbeitung: { label: 'In Bearbeitung', color: '#f59e0b' },
|
||||||
|
erledigt: { label: 'Erledigt', color: '#22c55e' },
|
||||||
|
ueberfaellig: { label: 'Überfällig', color: '#ef4444' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeStatusDistribution(assets) {
|
||||||
|
const offen = assets.filter((a) => a.status === 'offen').length;
|
||||||
|
const inBearbeitung = assets.filter((a) => a.status === 'in_bearbeitung').length;
|
||||||
|
const erledigt = assets.filter((a) => a.status === 'entsorgt').length;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7);
|
||||||
|
const ueberfaellig = assets.filter((a) => {
|
||||||
|
if (a.status === 'entsorgt') return false;
|
||||||
|
return new Date(a.$createdAt) <= cutoff;
|
||||||
|
}).length;
|
||||||
|
return [
|
||||||
|
{ status: 'offen', value: offen, fill: 'var(--color-offen)' },
|
||||||
|
{ status: 'inBearbeitung', value: inBearbeitung, fill: 'var(--color-inBearbeitung)' },
|
||||||
|
{ status: 'erledigt', value: erledigt, fill: 'var(--color-erledigt)' },
|
||||||
|
{ status: 'ueberfaellig', value: ueberfaellig, fill: 'var(--color-ueberfaellig)' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieOwnData = useMemo(() => computeStatusDistribution(ownAssets), [ownAssets]);
|
||||||
|
|
||||||
|
const pieAvgData = useMemo(() => {
|
||||||
|
if (allLocationsCount <= 1) return computeStatusDistribution(allAssets);
|
||||||
|
return computeStatusDistribution(allAssets).map((d) => ({
|
||||||
|
...d,
|
||||||
|
value: Math.round(d.value / allLocationsCount),
|
||||||
|
}));
|
||||||
|
}, [allAssets, allLocationsCount]);
|
||||||
|
|
||||||
|
const emptyPieData = [
|
||||||
|
{ status: 'offen', value: 0, fill: 'var(--color-offen)' },
|
||||||
|
{ status: 'inBearbeitung', value: 0, fill: 'var(--color-inBearbeitung)' },
|
||||||
|
{ status: 'erledigt', value: 0, fill: 'var(--color-erledigt)' },
|
||||||
|
{ status: 'ueberfaellig', value: 1, fill: 'hsl(var(--muted))' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pieOwnMonth = useMemo(() => {
|
||||||
|
const monthAssets = ownAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
||||||
|
const data = computeStatusDistribution(monthAssets);
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
// #region agent log
|
||||||
|
fetch('http://127.0.0.1:7886/ingest/990166f5-529c-4789-bcc2-9ebbe976f059',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'5a8700'},body:JSON.stringify({sessionId:'5a8700',location:'FilialleiterDashboard.jsx:pieOwnMonth',message:'pieOwnMonth computed',data:{monthAssetsCount:monthAssets.length,total,hasData:total>0},timestamp:Date.now(),runId:'post-fix',hypothesisId:'H1-fix'})}).catch(()=>{});
|
||||||
|
// #endregion
|
||||||
|
return { data: total > 0 ? data : emptyPieData, total };
|
||||||
|
}, [ownAssets, monthStart]);
|
||||||
|
|
||||||
|
const pieAvgMonth = useMemo(() => {
|
||||||
|
const monthAssets = allAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
||||||
|
let data;
|
||||||
|
if (allLocationsCount <= 1) {
|
||||||
|
data = computeStatusDistribution(monthAssets);
|
||||||
|
} else {
|
||||||
|
data = computeStatusDistribution(monthAssets).map((d) => ({
|
||||||
|
...d,
|
||||||
|
value: Math.round(d.value / allLocationsCount),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
// #region agent log
|
||||||
|
fetch('http://127.0.0.1:7886/ingest/990166f5-529c-4789-bcc2-9ebbe976f059',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'5a8700'},body:JSON.stringify({sessionId:'5a8700',location:'FilialleiterDashboard.jsx:pieAvgMonth',message:'pieAvgMonth computed',data:{monthAssetsCount:monthAssets.length,total,hasData:total>0},timestamp:Date.now(),runId:'post-fix',hypothesisId:'H1-fix'})}).catch(()=>{});
|
||||||
|
// #endregion
|
||||||
|
return { data: total > 0 ? data : emptyPieData, total };
|
||||||
|
}, [allAssets, allLocationsCount, monthStart]);
|
||||||
|
|
||||||
|
const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssets.length / allLocationsCount) : 0;
|
||||||
const ownTotal = ownAssets.length;
|
const ownTotal = ownAssets.length;
|
||||||
|
|
||||||
const employeeStats = useMemo(() => {
|
const employeeStats = useMemo(() => {
|
||||||
@@ -216,88 +287,227 @@ export default function FilialleiterDashboard() {
|
|||||||
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<Card className="mb-6">
|
||||||
<Card className="lg:col-span-2">
|
<CardContent className="pt-4">
|
||||||
<CardContent className="pt-4">
|
<div className="grid grid-cols-1 items-center gap-4 lg:grid-cols-[1fr_2fr_1fr]">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div>
|
<p className="mb-2 text-sm font-medium text-muted-foreground">Meine Filiale</p>
|
||||||
<div className="text-3xl font-bold">{todayCount}</div>
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
<p className="text-sm text-muted-foreground">Heute erfasst</p>
|
<PieChart>
|
||||||
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
{dayTrend.arrow} Gestern: {yesterdayCount}
|
<Pie
|
||||||
</p>
|
data={pieOwnData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="status"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
strokeWidth={4}
|
||||||
|
activeIndex={0}
|
||||||
|
activeShape={({ outerRadius = 0, ...props }) => (
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 6} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
|
const total = pieOwnData.reduce((s, d) => s + d.value, 0);
|
||||||
|
return (
|
||||||
|
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
{total}
|
||||||
|
</tspan>
|
||||||
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
|
Gesamt
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold">{todayCount}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Heute erfasst</p>
|
||||||
|
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
|
||||||
|
{dayTrend.arrow} Gestern: {yesterdayCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-[260px] w-full">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis axisLine={false} dataKey="day" tickLine={false} tickMargin={6} />
|
||||||
|
<YAxis axisLine={false} tickCount={5} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
||||||
|
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
||||||
|
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 h-[140px] w-full">
|
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<BarChart
|
<p className="mb-2 text-sm font-medium text-muted-foreground">⌀ Alle Filialen</p>
|
||||||
data={chartData}
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
<PieChart>
|
||||||
>
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<Pie
|
||||||
<XAxis
|
data={pieAvgData}
|
||||||
axisLine={false}
|
dataKey="value"
|
||||||
dataKey="day"
|
nameKey="status"
|
||||||
tickLine={false}
|
innerRadius={50}
|
||||||
tickMargin={6}
|
outerRadius={80}
|
||||||
/>
|
strokeWidth={4}
|
||||||
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
activeIndex={0}
|
||||||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
activeShape={({ outerRadius = 0, ...props }) => (
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
<Sector {...props} outerRadius={outerRadius + 6} />
|
||||||
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
)}
|
||||||
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
>
|
||||||
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
<Label
|
||||||
</BarChart>
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
|
const total = pieAvgData.reduce((s, d) => s + d.value, 0);
|
||||||
|
return (
|
||||||
|
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
{total}
|
||||||
|
</tspan>
|
||||||
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
|
⌀ Gesamt
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
<Card className="lg:col-span-2">
|
</Card>
|
||||||
<CardContent className="pt-4">
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-4 lg:grid-cols-[1fr_2fr_1fr]">
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<p className="mb-2 text-sm font-medium text-muted-foreground">Meine Filiale</p>
|
||||||
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
|
<Pie
|
||||||
|
data={pieOwnMonth.data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="status"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
strokeWidth={4}
|
||||||
|
activeIndex={0}
|
||||||
|
activeShape={({ outerRadius = 0, ...props }) => (
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 6} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
|
return (
|
||||||
|
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
{pieOwnMonth.total}
|
||||||
|
</tspan>
|
||||||
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
|
Monat
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold">{thisMonthCount}</div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Diesen Monat</p>
|
<div className="text-3xl font-bold">{thisMonthCount}</div>
|
||||||
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
|
<p className="text-sm text-muted-foreground">Diesen Monat</p>
|
||||||
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
|
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
|
||||||
</p>
|
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-[260px] w-full">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
||||||
|
<BarChart
|
||||||
|
data={monthChartData}
|
||||||
|
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis axisLine={false} dataKey="month" tickLine={false} tickMargin={6} />
|
||||||
|
<YAxis axisLine={false} tickCount={5} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
||||||
|
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
||||||
|
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 h-[140px] w-full">
|
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<BarChart
|
<p className="mb-2 text-sm font-medium text-muted-foreground">⌀ Alle Filialen</p>
|
||||||
data={monthChartData}
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
<PieChart>
|
||||||
>
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<Pie
|
||||||
<XAxis
|
data={pieAvgMonth.data}
|
||||||
axisLine={false}
|
dataKey="value"
|
||||||
dataKey="month"
|
nameKey="status"
|
||||||
tickLine={false}
|
innerRadius={50}
|
||||||
tickMargin={6}
|
outerRadius={80}
|
||||||
/>
|
strokeWidth={4}
|
||||||
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
activeIndex={0}
|
||||||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
activeShape={({ outerRadius = 0, ...props }) => (
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
<Sector {...props} outerRadius={outerRadius + 6} />
|
||||||
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
)}
|
||||||
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
>
|
||||||
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
<Label
|
||||||
</BarChart>
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
|
return (
|
||||||
|
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
|
{pieAvgMonth.total}
|
||||||
|
</tspan>
|
||||||
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
|
⌀ Monat
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
<Card>
|
</Card>
|
||||||
<CardContent className="pt-2">
|
|
||||||
<div className="text-3xl font-bold">{ownTotal}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Meine Filiale</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-2">
|
|
||||||
<div className="text-3xl font-bold">{avgAllFilialen}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">⌀ Alle Filialen</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
109
src/components/KommentarAnhaengeList.jsx
Normal file
109
src/components/KommentarAnhaengeList.jsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
|
||||||
|
import { isImageFilename } from '@/lib/kommentarAnhaenge';
|
||||||
|
import { FileText, Download } from 'lucide-react';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Bild mit Appwrite-Session: <img> sendet keine Cookies zu /view — daher fetch → Blob-URL. */
|
||||||
|
function AuthenticatedImagePreview({ viewUrl, alt, className }) {
|
||||||
|
const [src, setSrc] = useState(null);
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const blobRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
setStatus('loading');
|
||||||
|
setSrc(null);
|
||||||
|
if (blobRef.current) {
|
||||||
|
URL.revokeObjectURL(blobRef.current);
|
||||||
|
blobRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(viewUrl, { credentials: 'include', mode: 'cors', signal: ac.signal })
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
const u = URL.createObjectURL(blob);
|
||||||
|
blobRef.current = u;
|
||||||
|
setSrc(u);
|
||||||
|
setStatus('ok');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
|
setStatus('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ac.abort();
|
||||||
|
if (blobRef.current) {
|
||||||
|
URL.revokeObjectURL(blobRef.current);
|
||||||
|
blobRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [viewUrl]);
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<p className="border-t px-3 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
Vorschau nicht möglich. Bitte „Herunterladen“ nutzen.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'loading' || !src) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[120px] items-center justify-center border-t bg-muted/20 text-xs text-muted-foreground">
|
||||||
|
Vorschau wird geladen…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-background">
|
||||||
|
<img src={src} alt={alt} className={cn('max-h-72 w-full object-contain', className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KommentarAnhaengeList({ attachments }) {
|
||||||
|
if (!attachments?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Anhänge</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{attachments.map((a) => {
|
||||||
|
const viewUrl = storage.getFileView({ bucketId: ATTACHMENTS_BUCKET_ID, fileId: a.fileId });
|
||||||
|
const downloadUrl = storage.getFileDownload({ bucketId: ATTACHMENTS_BUCKET_ID, fileId: a.fileId });
|
||||||
|
const showImage = isImageFilename(a.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={a.fileId} className="overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" title={a.name}>
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'outline', size: 'sm' }),
|
||||||
|
'shrink-0 gap-1.5 no-underline'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" aria-hidden />
|
||||||
|
Herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{showImage && <AuthenticatedImagePreview viewUrl={viewUrl} alt={a.name} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client, Account, Databases, Teams } from "appwrite";
|
import { Client, Account, Databases, Teams, Storage } from "appwrite";
|
||||||
|
|
||||||
const client = new Client()
|
const client = new Client()
|
||||||
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
|
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
|
||||||
@@ -7,7 +7,12 @@ const client = new Client()
|
|||||||
const account = new Account(client);
|
const account = new Account(client);
|
||||||
const databases = new Databases(client);
|
const databases = new Databases(client);
|
||||||
const teams = new Teams(client);
|
const teams = new Teams(client);
|
||||||
|
const storage = new Storage(client);
|
||||||
|
|
||||||
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'defekttrack_db';
|
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'defekttrack_db';
|
||||||
|
|
||||||
export { client, account, databases, teams };
|
/** Storage-Bucket für Kommentar-Anhänge (Standard-ID entspricht npm run setup) */
|
||||||
|
export const ATTACHMENTS_BUCKET_ID =
|
||||||
|
import.meta.env.VITE_APPWRITE_ATTACHMENTS_BUCKET_ID || "defekttrack_anhaenge";
|
||||||
|
|
||||||
|
export { client, account, databases, teams, storage };
|
||||||
|
|||||||
57
src/lib/kommentarAnhaenge.js
Normal file
57
src/lib/kommentarAnhaenge.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/** Marker pro Zeile: [[ATTACH:fileId:encodedFilename]] */
|
||||||
|
|
||||||
|
export function extractAttachments(raw) {
|
||||||
|
if (!raw || typeof raw !== 'string') return { text: '', attachments: [] };
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const attachments = [];
|
||||||
|
const kept = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.trim().match(/^\[\[ATTACH:([^:]+):(.+)\]\]$/);
|
||||||
|
if (m) {
|
||||||
|
attachments.push({ fileId: m[1], name: safeDecode(m[2]) });
|
||||||
|
} else {
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: kept.join('\n').trim(), attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecode(s) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(s);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendAttachmentMarkers(kommentarText, items) {
|
||||||
|
const base = (kommentarText || '').trimEnd();
|
||||||
|
const lines = (items || []).map(
|
||||||
|
({ fileId, name }) => `[[ATTACH:${fileId}:${encodeURIComponent(name || 'datei')}]]`
|
||||||
|
);
|
||||||
|
if (!lines.length) return base;
|
||||||
|
return base ? `${base}\n${lines.join('\n')}` : lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKommentarForDisplay(raw) {
|
||||||
|
const { text, attachments } = extractAttachments(raw || '');
|
||||||
|
let subject = '';
|
||||||
|
let body = text;
|
||||||
|
const match = text.match(/^\*([^*]+)\*/);
|
||||||
|
if (match) {
|
||||||
|
subject = match[1].trim();
|
||||||
|
body = text.slice(match[0].length).trim();
|
||||||
|
}
|
||||||
|
return { subject, body, attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasInfoContent(raw) {
|
||||||
|
const { subject, body, attachments } = parseKommentarForDisplay(raw || '');
|
||||||
|
if (attachments.length) return true;
|
||||||
|
if (subject) return true;
|
||||||
|
return !!(body && body.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFilename(name) {
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || '');
|
||||||
|
}
|
||||||
@@ -1,26 +1,54 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
export default defineConfig({
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
resolve: {
|
/** Ziel der Appwrite-API für den Dev-Proxy /v1 → … (wenn Client VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1 nutzt) */
|
||||||
alias: {
|
function resolveAppwriteProxyTarget(env) {
|
||||||
'@': path.resolve(__dirname, './src'),
|
const explicit = (env.VITE_APPWRITE_PROXY_TARGET || '').trim().replace(/\/$/, '')
|
||||||
},
|
if (explicit) return explicit
|
||||||
},
|
|
||||||
server: {
|
const ep = (env.VITE_APPWRITE_ENDPOINT || '').trim()
|
||||||
proxy: {
|
if (!ep) return 'https://appwrite.webklar.com'
|
||||||
'/api': {
|
try {
|
||||||
target: 'http://localhost:3001',
|
const u = new URL(ep)
|
||||||
changeOrigin: true,
|
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||||
},
|
return 'https://appwrite.webklar.com'
|
||||||
'/v1': {
|
}
|
||||||
target: 'https://appwrite.webklar.com',
|
return u.origin
|
||||||
changeOrigin: true,
|
} catch {
|
||||||
secure: true,
|
return 'https://appwrite.webklar.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const proxyTarget = resolveAppwriteProxyTarget(env)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/v1': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
proxyTimeout: 120_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user