31 von 45 = ca. 69 %
31 punkter der todo liste abgeabeitet
This commit is contained in:
@@ -1,14 +1,28 @@
|
||||
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 { 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';
|
||||
|
||||
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
|
||||
const STATUS_MAP = { offen: 'offen', in_bearbeitung: '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'];
|
||||
@@ -20,6 +34,19 @@ function formatTimestamp(ts) {
|
||||
+ ' ' + 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>;
|
||||
}
|
||||
|
||||
export default function AssetDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -141,22 +168,47 @@ export default function AssetDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
kommentar: asset.kommentar || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-loading">Lade Asset…</div>
|
||||
<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="asset-detail-page">
|
||||
<div className="asset-detail-not-found">
|
||||
<h2>Asset nicht gefunden</h2>
|
||||
<p>Das Asset mit der ID <code>{id}</code> existiert nicht.</p>
|
||||
<button className="btn-back" onClick={() => navigate('/tracker')}>Zurück zur Übersicht</button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -165,154 +217,193 @@ export default function AssetDetail() {
|
||||
const overdue = isOverdue(asset);
|
||||
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-header">
|
||||
<button className="btn-back" onClick={() => navigate('/tracker')}>← Zurück</button>
|
||||
<h1>
|
||||
Asset: <span style={{ color: '#1565C0' }}>{asset.erlNummer || '–'}</span>
|
||||
<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>
|
||||
<div className="asset-detail-meta">
|
||||
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
|
||||
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
|
||||
{overdue && <span className="age-warn">Überfällig ({days} Tage)</span>}
|
||||
</div>
|
||||
<StatusBadge status={asset.status} />
|
||||
<PrioBadge prio={asset.prio} />
|
||||
{overdue && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Überfällig ({days} Tage)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-detail-card">
|
||||
<div className="asset-detail-card-header">
|
||||
<h2>Eigenschaften</h2>
|
||||
{!editing ? (
|
||||
<button className="btn-edit" onClick={() => setEditing(true)}>Bearbeiten</button>
|
||||
) : (
|
||||
<div className="edit-actions">
|
||||
<button className="btn-save" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn-cancel" onClick={() => { 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',
|
||||
kommentar: asset.kommentar || '',
|
||||
}); }}>Abbrechen</button>
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-props-grid">
|
||||
<PropertyRow label="ERL-Nr." value={form.erlNummer} field="erlNummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
|
||||
<PropertyRow label="Artikelnr." value={form.artikelNr} field="artikelNr" editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
|
||||
<PropertyRow label="Bezeichnung" value={form.bezeichnung} field="bezeichnung" editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
|
||||
<PropertyRow label="Seriennummer" value={form.seriennummer} field="seriennummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
|
||||
<PropertyRow label="Defekt" value={form.defekt} field="defekt" editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea />
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Lagerstandort</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.lagerstandortId} onChange={(e) => setForm(f => ({ ...f, lagerstandortId: e.target.value }))}>
|
||||
<option value="">– Kein Standort –</option>
|
||||
{activeLagerstandorte.map((l) => (
|
||||
<option key={l.$id} value={l.$id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="prop-value">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || '–'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Zuständig</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.zustaendig} onChange={(e) => setForm(f => ({ ...f, zustaendig: e.target.value }))}>
|
||||
<option value="">– Mitarbeiter wählen –</option>
|
||||
{colleagues.map((c) => (
|
||||
<option key={c.userId} value={c.userName}>
|
||||
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="prop-value">{asset.zustaendig || '–'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Status</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.status} onChange={(e) => setForm(f => ({ ...f, status: e.target.value }))}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Priorität</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.prio} onChange={(e) => setForm(f => ({ ...f, prio: e.target.value }))}>
|
||||
{PRIO_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>{PRIO_LABELS[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
|
||||
)}
|
||||
</div>
|
||||
<PropertyRow label="Kommentar" value={form.kommentar} field="kommentar" editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea />
|
||||
</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>
|
||||
|
||||
<div className="asset-info-footer">
|
||||
{/* 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>
|
||||
|
||||
<PropertyField label="Kommentar" value={form.kommentar} editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
|
||||
</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>{asset.createdBy || '–'}</strong></span>
|
||||
<span>Zuletzt bearbeitet von: <strong>{asset.lastEditedBy || '–'}</strong></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>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<div className="asset-log-card">
|
||||
<h2>Änderungsprotokoll</h2>
|
||||
<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';
|
||||
{/* 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>
|
||||
</div>
|
||||
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 PropertyRow({ label, value, editing, onChange, mono, textarea }) {
|
||||
function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
|
||||
return (
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">{label}</span>
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
<Label>{label}</Label>
|
||||
{editing ? (
|
||||
textarea ? (
|
||||
<textarea className="prop-input" value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
|
||||
<Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
|
||||
) : (
|
||||
<input className="prop-input" type="text" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
<Input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
) : (
|
||||
<span className={`prop-value${mono ? ' mono' : ''}`}>{value || '–'}</span>
|
||||
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '–'}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user