476 lines
19 KiB
JavaScript
476 lines
19 KiB
JavaScript
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>
|
||
);
|
||
}
|