kuchen diagram update

This commit is contained in:
2026-04-02 15:56:03 +02:00
parent ad02198671
commit 2587238b3e
14 changed files with 833 additions and 119 deletions

View File

@@ -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

14
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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": {

View 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();

View File

@@ -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) => {

View File

@@ -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 <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() {
const { id } = useParams();
const navigate = useNavigate();
@@ -348,7 +369,18 @@ export default function AssetDetail() {
)}
</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>
</CardContent>
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">

View File

@@ -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 (
<Dialog open={true} onOpenChange={onClose}>
@@ -35,8 +30,9 @@ export default function CommentPopup({ artikel, onClose }) {
</div>
)}
<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>
<KommentarAnhaengeList attachments={attachments} />
</div>
<DialogFooter>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -12,6 +12,11 @@ import {
SelectTrigger,
SelectValue,
} 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 = {
erlNummer: '',
@@ -25,10 +30,24 @@ const EMPTY_FORM = {
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 }) {
const { user } = useAuth();
const ownName = user?.name || user?.email || '';
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(() => {
if (ownName && !form.zustaendig) {
@@ -45,6 +64,60 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
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) {
e.preventDefault();
@@ -53,6 +126,12 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
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 {
await onAdd({
erlNummer: form.erlNummer.trim(),
@@ -63,10 +142,11 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
lagerstandortId: form.lagerstandortId,
zustaendig: form.zustaendig.trim(),
prio: form.prio,
kommentar: form.kommentar.trim(),
kommentar: built,
});
showToast('Asset erfasst: ' + form.erlNummer.trim());
setForm({ ...EMPTY_FORM, zustaendig: ownName });
setAttachments([]);
} catch {
showToast('Fehler beim Speichern!', '#C62828');
}
@@ -192,7 +272,84 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
/>
</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
</Button>
</form>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { hasInfoContent } from '@/lib/kommentarAnhaenge';
import { useAuth } from '../context/AuthContext';
import CommentPopup from './CommentPopup';
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' };
/** 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 }) {
const { user } = useAuth();
const navigate = useNavigate();
@@ -202,7 +207,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
</Button>
</div>
<Table>
<Table className="border-separate border-spacing-y-2">
<TableHeader>
<TableRow>
<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 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
? 'bg-muted/50'
: '';
@@ -250,7 +255,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
return (
<TableRow
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'}`}>
{a.erlNummer || ''}
@@ -301,9 +306,9 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
<Button
variant="outline"
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"
onClick={() => a.kommentar && setCommentAsset(a)}
onClick={() => hasInfoContent(a.kommentar) && setCommentAsset(a)}
>
Info
</Button>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
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 { Query } from 'appwrite';
import Header from './Header';
@@ -80,7 +80,7 @@ export default function FilialleiterDashboard() {
const locationId = userMeta?.locationId || '';
const [ownAssets, setOwnAssets] = useState([]);
const [allAssetsTotal, setAllAssetsTotal] = useState(0);
const [allAssets, setAllAssets] = useState([]);
const [allLocationsCount, setAllLocationsCount] = useState(1);
const [colleagues, setColleagues] = useState([]);
@@ -102,7 +102,7 @@ export default function FilialleiterDashboard() {
const lagerIds = new Set(lagerRes.documents.map((l) => l.$id));
const assetsForLocation = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId));
setOwnAssets(assetsForLocation);
setAllAssetsTotal(assetsRes.total);
setAllAssets(assetsRes.documents);
setAllLocationsCount(Math.max(locsRes.total, 1));
setColleagues(metaRes.documents.filter((d) => d.userName));
} 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 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>
</div>
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card className="lg:col-span-2">
<CardContent className="pt-4">
<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>
<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={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 className="mt-4 h-[140px] 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={4} 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>
<div className="flex flex-col items-center justify-center">
<p className="mb-2 text-sm font-medium text-muted-foreground"> Alle Filialen</p>
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
<Pie
data={pieAvgData}
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 = 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>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardContent className="pt-4">
</div>
</CardContent>
</Card>
<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 className="text-3xl font-bold">{thisMonthCount}</div>
<p className="text-sm text-muted-foreground">Diesen Monat</p>
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
</p>
<div>
<div className="text-3xl font-bold">{thisMonthCount}</div>
<p className="text-sm text-muted-foreground">Diesen Monat</p>
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
{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 className="mt-4 h-[140px] 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={4} 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>
<div className="flex flex-col items-center justify-center">
<p className="mb-2 text-sm font-medium text-muted-foreground"> Alle Filialen</p>
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
<Pie
data={pieAvgMonth.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}>
{pieAvgMonth.total}
</tspan>
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
Monat
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
</div>
</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>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>

View 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>
);
}

View File

@@ -1,4 +1,4 @@
import { Client, Account, Databases, Teams } from "appwrite";
import { Client, Account, Databases, Teams, Storage } from "appwrite";
const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
@@ -7,7 +7,12 @@ const client = new Client()
const account = new Account(client);
const databases = new Databases(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 { 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 };

View 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 || '');
}

View File

@@ -1,26 +1,54 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { fileURLToPath } from 'url'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/v1': {
target: 'https://appwrite.webklar.com',
changeOrigin: true,
secure: true,
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/** Ziel der Appwrite-API für den Dev-Proxy /v1 → … (wenn Client VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1 nutzt) */
function resolveAppwriteProxyTarget(env) {
const explicit = (env.VITE_APPWRITE_PROXY_TARGET || '').trim().replace(/\/$/, '')
if (explicit) return explicit
const ep = (env.VITE_APPWRITE_ENDPOINT || '').trim()
if (!ep) return 'https://appwrite.webklar.com'
try {
const u = new URL(ep)
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
return 'https://appwrite.webklar.com'
}
return u.origin
} catch {
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,
},
},
},
}
})