feat: initial commit

This commit is contained in:
2026-03-08 08:34:55 +01:00
parent 3eb7c3ca8e
commit 43c9efd8f5
39 changed files with 13242 additions and 688 deletions

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