diff --git a/.env.example b/.env.example
index 5d14b7f..fd5c5bf 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,13 @@ VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=dein-projekt-id
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
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_API_KEY=dein-api-key-hier
diff --git a/package-lock.json b/package-lock.json
index a1523e8..d7b9606 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -100,6 +100,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1617,6 +1618,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2362,6 +2364,7 @@
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2427,6 +2430,7 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2825,6 +2829,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3943,6 +3948,7 @@
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4226,6 +4232,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -4914,6 +4921,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -6845,6 +6853,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7085,6 +7094,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7094,6 +7104,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -7217,6 +7228,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
@@ -8500,6 +8512,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz",
"integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.24.2",
"postcss": "^8.5.2",
@@ -8890,6 +8903,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 2020e5e..7f71d80 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"lint": "eslint .",
"preview": "vite preview",
"setup": "node scripts/setup-appwrite.js",
+ "setup:storage": "node scripts/create-attachments-bucket.js",
"seed:dummy": "node scripts/seed-dummy-data.js"
},
"dependencies": {
diff --git a/scripts/create-attachments-bucket.js b/scripts/create-attachments-bucket.js
new file mode 100644
index 0000000..d46a835
--- /dev/null
+++ b/scripts/create-attachments-bucket.js
@@ -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();
diff --git a/scripts/setup-appwrite.js b/scripts/setup-appwrite.js
index 4267c49..f525eb1 100644
--- a/scripts/setup-appwrite.js
+++ b/scripts/setup-appwrite.js
@@ -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 { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -39,6 +39,9 @@ const client = new Client()
const databases = new Databases(client);
const teamsService = new Teams(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'];
@@ -180,7 +183,7 @@ async function createAssetsCollection() {
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, '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, 'lastEditedBy', 128, false, '');
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)');
}
+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() {
for (const role of TEAM_ROLES) {
try {
@@ -330,6 +354,9 @@ async function main() {
await createDatabase();
console.log('');
+ await createAttachmentsBucket();
+ console.log('');
+
await createLocationsCollection();
console.log('');
@@ -361,6 +388,9 @@ async function main() {
console.log(' Passwort: Admin1234!');
console.log('');
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) => {
diff --git a/src/components/AssetDetail.jsx b/src/components/AssetDetail.jsx
index d0da64b..f2dda16 100644
--- a/src/components/AssetDetail.jsx
+++ b/src/components/AssetDetail.jsx
@@ -21,6 +21,8 @@ import {
SelectValue,
} from '@/components/ui/select';
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 PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
@@ -47,6 +49,25 @@ function PrioBadge({ prio }) {
return {PRIO_LABELS[prio]};
}
+function KommentarReadonly({ value }) {
+ const { subject, body, attachments } = parseKommentarForDisplay(value);
+ const empty = !subject && !(body && body.trim()) && attachments.length === 0;
+ if (empty) return
–
;
+ return (
+
+ {subject && (
+
+ {subject}
+
+ )}
+ {body && body.trim() ? (
+
{body}
+ ) : null}
+
+
+ );
+}
+
export default function AssetDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -348,7 +369,18 @@ export default function AssetDetail() {
)}
- setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
+
+
+ {editing ? (
+
diff --git a/src/components/CommentPopup.jsx b/src/components/CommentPopup.jsx
index 6d57521..6de9356 100644
--- a/src/components/CommentPopup.jsx
+++ b/src/components/CommentPopup.jsx
@@ -7,16 +7,11 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
+import { parseKommentarForDisplay } from '@/lib/kommentarAnhaenge';
+import KommentarAnhaengeList from '@/components/KommentarAnhaengeList';
export default function CommentPopup({ artikel, onClose }) {
- let subject = '';
- let text = artikel.kommentar;
-
- const match = artikel.kommentar.match(/^\*([^*]+)\*/);
- if (match) {
- subject = match[1].trim();
- text = artikel.kommentar.substring(match[0].length).trim();
- }
+ const { subject, body, attachments } = parseKommentarForDisplay(artikel?.kommentar);
return (