feat: initial commit
This commit is contained in:
319
src/components/AssetDetail.jsx
Normal file
319
src/components/AssetDetail.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
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';
|
||||
|
||||
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'];
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
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',
|
||||
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: '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 === '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);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-loading">Lade Asset…</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const days = getDaysOld(asset.$createdAt);
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<div className="asset-info-footer">
|
||||
<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>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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';
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyRow({ label, value, editing, onChange, mono, textarea }) {
|
||||
return (
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">{label}</span>
|
||||
{editing ? (
|
||||
textarea ? (
|
||||
<textarea className="prop-input" value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
|
||||
) : (
|
||||
<input className="prop-input" type="text" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
) : (
|
||||
<span className={`prop-value${mono ? ' mono' : ''}`}>{value || '–'}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user