Files
assetsTracker/src/components/AssetDetail.jsx
2026-04-07 17:12:32 +02:00

476 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { useAuth } from '@/context/AuthContext';
import { useAuditLog } from '@/hooks/useAuditLog';
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
import { useColleagues } from '@/hooks/useColleagues';
import { getDaysOld, isOverdue } from '@/hooks/useAssets';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
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' };
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
const STATUS_OPTIONS = ['offen', 'in_bearbeitung', 'entsorgt'];
const BEARB_STATUS_LABELS = {
'': 'Nicht gesetzt',
portalpruefung: '\u{1F50D} Portalprüfung durchführen',
gutschreiben_entsorgen: '\u267B\uFE0F Direkt gutschreiben & entsorgen',
zurueck_hersteller: '\u{1F4E6} Zurück an Hersteller senden',
defekt_ankunft: '\u26A0\uFE0F Defekt bei Ankunft melden',
};
const BEARB_STATUS_OPTIONS = ['', 'portalpruefung', 'gutschreiben_entsorgen', 'zurueck_hersteller', 'defekt_ankunft'];
function formatTimestamp(ts) {
if (!ts) return '';
const d = new Date(ts);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function StatusBadge({ status }) {
if (status === 'offen') return <Badge variant="destructive">{STATUS_LABEL[status]}</Badge>;
if (status === 'in_bearbeitung') return <Badge className="border-amber-300 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">{STATUS_LABEL[status]}</Badge>;
return <Badge variant="secondary">{STATUS_LABEL[status]}</Badge>;
}
function PrioBadge({ prio }) {
if (prio === 'kritisch') return <Badge variant="destructive">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'hoch') return <Badge className="border-orange-300 bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'mittel') return <Badge className="border-yellow-300 bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-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() {
const { id } = useParams();
const navigate = useNavigate();
const { user, userMeta } = useAuth();
const { logs, loadingLogs, loadLogs, addLog } = useAuditLog();
const locationId = userMeta?.locationId || '';
const { activeLagerstandorte } = useLagerstandorte(locationId);
const { colleagues } = useColleagues(locationId);
const [asset, setAsset] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({});
const loadAsset = useCallback(async () => {
try {
const doc = await databases.getDocument(DATABASE_ID, 'assets', id);
setAsset(doc);
setForm({
erlNummer: doc.erlNummer || '',
seriennummer: doc.seriennummer || '',
artikelNr: doc.artikelNr || '',
bezeichnung: doc.bezeichnung || '',
defekt: doc.defekt || '',
lagerstandortId: doc.lagerstandortId || '',
zustaendig: doc.zustaendig || '',
status: doc.status || 'offen',
prio: doc.prio || 'mittel',
bearbeitungsStatus: doc.bearbeitungsStatus || '',
kommentar: doc.kommentar || '',
});
} catch (err) {
console.error('Asset laden fehlgeschlagen:', err);
setAsset(null);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
loadAsset();
loadLogs(id);
}, [loadAsset, loadLogs, id]);
const userName = user?.name || user?.email || 'Unbekannt';
function buildChangeDetails(oldAsset, newForm) {
const fields = [
{ key: 'erlNummer', label: 'ERL-Nr.' },
{ key: 'seriennummer', label: 'Seriennummer' },
{ key: 'artikelNr', label: 'Artikelnr.' },
{ key: 'bezeichnung', label: 'Bezeichnung' },
{ key: 'defekt', label: 'Defekt' },
{ key: 'lagerstandortId', label: 'Lagerstandort' },
{ key: 'zustaendig', label: 'Zuständig' },
{ key: 'status', label: 'Status' },
{ key: 'prio', label: 'Priorität' },
{ key: 'bearbeitungsStatus', label: 'Bearbeitungsstatus' },
{ key: 'kommentar', label: 'Kommentar' },
];
const changes = [];
for (const f of fields) {
const oldVal = oldAsset[f.key] || '';
const newVal = newForm[f.key] || '';
if (oldVal !== newVal) {
if (f.key === 'status') {
changes.push(`${f.label}: ${STATUS_LABEL[oldVal] || oldVal}${STATUS_LABEL[newVal] || newVal}`);
} else if (f.key === 'bearbeitungsStatus') {
changes.push(`${f.label}: ${BEARB_STATUS_LABELS[oldVal] || oldVal || 'Nicht gesetzt'}${BEARB_STATUS_LABELS[newVal] || newVal || 'Nicht gesetzt'}`);
} else if (f.key === 'prio') {
changes.push(`${f.label}: ${PRIO_LABELS[oldVal] || oldVal}${PRIO_LABELS[newVal] || newVal}`);
} else {
changes.push(`${f.label}: "${oldVal}" → "${newVal}"`);
}
}
}
return changes.join('; ');
}
async function handleSave() {
if (!asset) return;
setSaving(true);
try {
const changeDetails = buildChangeDetails(asset, form);
if (!changeDetails) {
setEditing(false);
setSaving(false);
return;
}
const updated = await databases.updateDocument(DATABASE_ID, 'assets', id, {
...form,
lastEditedBy: userName,
});
setAsset(updated);
let logDetails = changeDetails;
if (asset.zustaendig !== form.zustaendig && form.zustaendig) {
const isSelf = form.zustaendig === userName;
const reassignInfo = isSelf
? `${userName} hat sich das Asset selbst zugewiesen`
: `${userName} hat das Asset ${form.zustaendig} zugewiesen`;
logDetails = reassignInfo + (changeDetails.replace(/Zuständig:[^;]*;?\s?/, '').trim()
? '; ' + changeDetails.replace(/Zuständig:[^;]*;?\s?/, '').trim()
: '');
}
await addLog({
assetId: id,
action: 'bearbeitet',
details: logDetails,
userId: user.$id,
userName,
});
setEditing(false);
} catch (err) {
console.error('Speichern fehlgeschlagen:', err);
alert('Speichern fehlgeschlagen: ' + (err.message || err));
} finally {
setSaving(false);
}
}
function resetForm() {
setEditing(false);
setForm({
erlNummer: asset.erlNummer || '',
seriennummer: asset.seriennummer || '',
artikelNr: asset.artikelNr || '',
bezeichnung: asset.bezeichnung || '',
defekt: asset.defekt || '',
lagerstandortId: asset.lagerstandortId || '',
zustaendig: asset.zustaendig || '',
status: asset.status || 'offen',
prio: asset.prio || 'mittel',
bearbeitungsStatus: asset.bearbeitungsStatus || '',
kommentar: asset.kommentar || '',
});
}
if (loading) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center gap-4 p-6 pt-24">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (!asset) {
return (
<div className="mx-auto max-w-4xl p-6 pt-12">
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Asset nicht gefunden</h2>
<p className="text-sm text-muted-foreground">
Das Asset mit der ID <code className="rounded bg-muted px-1 py-0.5 text-xs">{id}</code> existiert nicht.
</p>
<Button variant="outline" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Zurück zur Übersicht
</Button>
</CardContent>
</Card>
</div>
);
}
const days = getDaysOld(asset.$createdAt);
const overdue = isOverdue(asset);
return (
<div className="mx-auto max-w-4xl p-6">
{/* Back button */}
<Button variant="outline" className="mb-4" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Zurück
</Button>
{/* Header area */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">
Asset: <span className="text-blue-600 dark:text-blue-400">{asset.erlNummer || ''}</span>
</h1>
<StatusBadge status={asset.status} />
<PrioBadge prio={asset.prio} />
{overdue && (
<Badge variant="destructive" className="text-xs">
Überfällig ({days} Tage)
</Badge>
)}
</div>
{/* Properties card */}
<Card className="mb-6">
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Eigenschaften</CardTitle>
<div className="flex gap-2">
{!editing ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Bearbeiten
</Button>
) : (
<>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="mr-1.5 h-3.5 w-3.5" />
{saving ? 'Speichern…' : 'Speichern'}
</Button>
<Button variant="outline" size="sm" onClick={resetForm}>
<X className="mr-1.5 h-3.5 w-3.5" />
Abbrechen
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<PropertyField label="ERL-Nr." value={form.erlNummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
<PropertyField label="Artikelnr." value={form.artikelNr} editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
<PropertyField label="Bezeichnung" value={form.bezeichnung} editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
<PropertyField label="Seriennummer" value={form.seriennummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
<PropertyField label="Defekt" value={form.defekt} editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea className="sm:col-span-2" />
{/* Lagerstandort */}
<div className="space-y-1.5">
<Label>Lagerstandort</Label>
{editing ? (
<Select value={form.lagerstandortId} onValueChange={(v) => setForm(f => ({ ...f, lagerstandortId: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Kein Standort" />
</SelectTrigger>
<SelectContent>
{activeLagerstandorte.map((l) => (
<SelectItem key={l.$id} value={l.$id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</p>
)}
</div>
{/* Zuständig */}
<div className="space-y-1.5">
<Label>Zuständig</Label>
{editing ? (
<Select value={form.zustaendig} onValueChange={(v) => setForm(f => ({ ...f, zustaendig: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Mitarbeiter wählen" />
</SelectTrigger>
<SelectContent>
{colleagues.map((c) => (
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{asset.zustaendig || ''}</p>
)}
</div>
{/* Status */}
<div className="space-y-1.5">
<Label>Status</Label>
{editing ? (
<Select value={form.status} onValueChange={(v) => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABEL[s]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge status={asset.status} />
)}
</div>
{/* Priorität */}
<div className="space-y-1.5">
<Label>Priorität</Label>
{editing ? (
<Select value={form.prio} onValueChange={(v) => setForm(f => ({ ...f, prio: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIO_OPTIONS.map((p) => (
<SelectItem key={p} value={p}>{PRIO_LABELS[p]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<PrioBadge prio={asset.prio} />
)}
</div>
{(form.status === 'in_bearbeitung' || asset.bearbeitungsStatus) && (
<div className="space-y-1.5 sm:col-span-2">
<Label>Bearbeitungsstatus</Label>
{editing ? (
<Select value={form.bearbeitungsStatus || '_none'} onValueChange={(v) => setForm(f => ({ ...f, bearbeitungsStatus: v === '_none' ? '' : v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEARB_STATUS_OPTIONS.map((s) => (
<SelectItem key={s || '_none'} value={s || '_none'}>{BEARB_STATUS_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{BEARB_STATUS_LABELS[asset.bearbeitungsStatus || ''] || ''}</p>
)}
</div>
)}
<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">
<span>Erstellt am: {formatTimestamp(asset.$createdAt)}</span>
<span>Erstellt von: <strong className="text-foreground">{asset.createdBy || ''}</strong></span>
<span>Zuletzt bearbeitet von: <strong className="text-foreground">{asset.lastEditedBy || ''}</strong></span>
<span>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span>
</CardFooter>
</Card>
{/* Audit log card */}
<Card>
<CardHeader>
<CardTitle>Änderungsprotokoll</CardTitle>
</CardHeader>
<CardContent>
<div className="log-console">
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen</div>}
{!loadingLogs && logs.length === 0 && (
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
)}
{logs.map((log) => {
const ts = formatTimestamp(log.$createdAt);
const actionClass = log.action === 'erstellt' ? 'log-created'
: log.action === 'status_geaendert' ? 'log-status'
: 'log-edit';
return (
<div key={log.$id} className={`log-entry ${actionClass}`}>
<span className="log-time">[{ts}]</span>
<span className="log-user">{log.userName}</span>
<span className="log-action">{log.action.toUpperCase()}</span>
{log.details && <span className="log-details">{log.details}</span>}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
return (
<div className={`space-y-1.5 ${className}`}>
<Label>{label}</Label>
{editing ? (
textarea ? (
<Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
) : (
<Input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
)
) : (
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || ''}</p>
)}
</div>
);
}