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,268 @@
import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { ID, Query } from 'appwrite';
import Header from './Header';
import Toast from './Toast';
import { useToast } from '../hooks/useToast';
import { useAuth } from '../context/AuthContext';
import LagerstandortManager from './LagerstandortManager';
import { useLagerstandorte } from '../hooks/useLagerstandorte';
export default function AdminPanel() {
const { user, userMeta } = useAuth();
const { toast, showToast } = useToast();
const locationId = userMeta?.locationId || '';
const { lagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
const [stats, setStats] = useState({ users: 0, locations: 0, assets: 0, lagerstandorte: 0 });
const [locations, setLocations] = useState([]);
const [usersList, setUsersList] = useState([]);
const [showLsManager, setShowLsManager] = useState(false);
const [newFiliale, setNewFiliale] = useState({ name: '', address: '' });
const [addingFiliale, setAddingFiliale] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({ name: '', address: '' });
const loadData = useCallback(async () => {
try {
const [locsRes, usersRes, assetsRes, lsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(200)]),
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(1)]),
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [Query.limit(1)]),
]);
setLocations(locsRes.documents);
setUsersList(usersRes.documents);
setStats({
users: usersRes.total,
locations: locsRes.total,
assets: assetsRes.total,
lagerstandorte: lsRes.total,
});
} catch (err) {
console.error('Admin-Daten laden fehlgeschlagen:', err);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
async function handleAddFiliale(e) {
e.preventDefault();
if (!newFiliale.name.trim()) return;
setAddingFiliale(true);
try {
const doc = await databases.createDocument(DATABASE_ID, 'locations', ID.unique(), {
name: newFiliale.name.trim(),
address: newFiliale.address.trim(),
isActive: true,
});
setLocations((prev) => [...prev, doc]);
setStats((s) => ({ ...s, locations: s.locations + 1 }));
setNewFiliale({ name: '', address: '' });
showToast(`Filiale "${doc.name}" erstellt`);
} catch (err) {
showToast('Fehler beim Erstellen: ' + (err.message || err), '#C62828');
} finally {
setAddingFiliale(false);
}
}
async function handleToggleFiliale(id) {
const loc = locations.find((l) => l.$id === id);
if (!loc) return;
try {
const updated = await databases.updateDocument(DATABASE_ID, 'locations', id, {
isActive: !loc.isActive,
});
setLocations((prev) => prev.map((l) => l.$id === id ? updated : l));
showToast(`Filiale "${loc.name}" ${updated.isActive ? 'aktiviert' : 'deaktiviert'}`);
} catch (err) {
showToast('Fehler: ' + (err.message || err), '#C62828');
}
}
async function handleDeleteFiliale(id) {
const loc = locations.find((l) => l.$id === id);
if (!window.confirm(`Filiale "${loc?.name}" wirklich löschen?`)) return;
try {
await databases.deleteDocument(DATABASE_ID, 'locations', id);
setLocations((prev) => prev.filter((l) => l.$id !== id));
setStats((s) => ({ ...s, locations: s.locations - 1 }));
showToast(`Filiale "${loc.name}" gelöscht`, '#607D8B');
} catch (err) {
showToast('Fehler beim Löschen: ' + (err.message || err), '#C62828');
}
}
function startEdit(loc) {
setEditingId(loc.$id);
setEditForm({ name: loc.name, address: loc.address || '' });
}
async function handleSaveEdit() {
if (!editForm.name.trim()) return;
try {
const updated = await databases.updateDocument(DATABASE_ID, 'locations', editingId, {
name: editForm.name.trim(),
address: editForm.address.trim(),
});
setLocations((prev) => prev.map((l) => l.$id === editingId ? updated : l));
setEditingId(null);
showToast(`Filiale "${updated.name}" gespeichert`);
} catch (err) {
showToast('Fehler beim Speichern: ' + (err.message || err), '#C62828');
}
}
return (
<>
<Header showToast={showToast} />
<div className="panel-page">
<div className="panel-title-bar">
<h1>Admin Panel</h1>
<p>System-Übersicht und Verwaltung</p>
</div>
<div className="panel-stats">
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.users}</div>
<div className="panel-stat-label">Benutzer</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.locations}</div>
<div className="panel-stat-label">Filialen</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.assets}</div>
<div className="panel-stat-label">Assets gesamt</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.lagerstandorte}</div>
<div className="panel-stat-label">Lagerstandorte</div>
</div>
</div>
<div className="panel-grid">
<div className="panel-card">
<h2>Filialen verwalten</h2>
<form className="filiale-add-form" onSubmit={handleAddFiliale}>
<input
type="text"
className="filiale-input"
value={newFiliale.name}
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname (z.B. Kaiserslautern)"
/>
<input
type="text"
className="filiale-input"
value={newFiliale.address}
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse (optional)"
/>
<button type="submit" className="btn-panel-action" disabled={addingFiliale || !newFiliale.name.trim()}>
{addingFiliale ? '...' : 'Filiale hinzufügen'}
</button>
</form>
<div className="panel-list" style={{ marginTop: 16 }}>
{locations.length === 0 && <p className="panel-empty">Keine Filialen vorhanden</p>}
{locations.map((loc) => (
<div key={loc.$id} className={`filiale-admin-item ${loc.isActive ? '' : 'inactive'}`}>
{editingId === loc.$id ? (
<div className="filiale-edit-row">
<input
type="text"
className="filiale-input"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname"
/>
<input
type="text"
className="filiale-input"
value={editForm.address}
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse"
/>
<div className="filiale-edit-btns">
<button className="btn-action btn-status" onClick={handleSaveEdit}>Speichern</button>
<button className="btn-action btn-info" onClick={() => setEditingId(null)}>Abbrechen</button>
</div>
</div>
) : (
<>
<div className="filiale-admin-info">
<strong>{loc.name}</strong>
{loc.address && <span className="panel-list-sub">{loc.address}</span>}
<span className={`panel-badge ${loc.isActive ? 'active' : 'inactive'}`}>
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div className="filiale-admin-actions">
<button className="btn-action btn-info" onClick={() => startEdit(loc)}>Bearbeiten</button>
<button className="btn-action btn-status" onClick={() => handleToggleFiliale(loc.$id)}>
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button className="btn-action btn-delete" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</button>
</div>
</>
)}
</div>
))}
</div>
</div>
<div className="panel-card">
<h2>Benutzer</h2>
<div className="panel-list">
{usersList.length === 0 && <p className="panel-empty">Keine Benutzer vorhanden</p>}
{usersList.map((u) => {
const loc = locations.find((l) => l.$id === u.locationId);
return (
<div key={u.$id} className="panel-list-item">
<div>
<strong>{u.userName || u.userId}</strong>
<span className="panel-list-sub">{u.role}</span>
</div>
<span className="panel-list-sub">{loc?.name || ''}</span>
</div>
);
})}
</div>
</div>
<div className="panel-card">
<h2>Lagerstandorte</h2>
<button className="btn-panel-action" onClick={() => setShowLsManager(true)}>
Lagerstandorte verwalten
</button>
<div className="panel-list" style={{ marginTop: 12 }}>
{lagerstandorte.map((l) => (
<div key={l.$id} className="panel-list-item">
<span>{l.name}</span>
<span className={`panel-badge ${l.isActive ? 'active' : 'inactive'}`}>
{l.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{showLsManager && (
<LagerstandortManager
lagerstandorte={lagerstandorte}
onAdd={addLagerstandort}
onToggle={toggleLagerstandort}
onDelete={deleteLagerstandort}
onClose={() => setShowLsManager(false)}
/>
)}
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
);
}

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

View File

@@ -0,0 +1,69 @@
import { useState, useRef, useEffect } from 'react';
export default function ColumnFilter({ label, active, summary, children, onOpen, onClose }) {
const ref = useRef(null);
useEffect(() => {
if (!active) return;
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [active, onClose]);
return (
<th className="col-filter-th" ref={ref}>
<button className={`col-filter-btn ${active ? 'active' : ''} ${summary ? 'has-filter' : ''}`} onClick={active ? onClose : onOpen}>
<span className="col-filter-label">{label}</span>
{summary && <span className="col-filter-summary">{summary}</span>}
<span className={`col-filter-arrow ${active ? 'open' : ''}`}>&#9662;</span>
</button>
{active && (
<div className="col-filter-popup">
{children}
</div>
)}
</th>
);
}
export function TextFilter({ value, onChange, placeholder }) {
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
return (
<input
ref={inputRef}
className="col-filter-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Suchen...'}
/>
);
}
export function SelectFilter({ value, onChange, options }) {
return (
<div className="col-filter-options">
<button
className={`col-filter-option ${!value ? 'selected' : ''}`}
onClick={() => onChange('')}
>
Alle
</button>
{options.map((opt) => (
<button
key={opt.value}
className={`col-filter-option ${value === opt.value ? 'selected' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
export default function CommentPopup({ artikel, onClose }) {
let subject = '';
let text = artikel.kommentar;
const match = artikel.kommentar.match(/^\*([^*]+)\*/);
if (match) {
subject = match[1].trim();
text = artikel.kommentar.substring(match[0].length).trim();
}
return (
<>
<div className="comment-overlay" onClick={onClose} />
<div className="comment-popup">
<h3>Kommentar zu {artikel.erlNummer}</h3>
{subject && <div className="subject">{subject}</div>}
<div className="text">{text || '(Kein weiterer Kommentar)'}</div>
<button className="close-btn" onClick={onClose}>Schließen</button>
</div>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react';
import { isOverdue } from '../hooks/useAssets';
import { useAuth } from '../context/AuthContext';
import LagerstandortManager from './LagerstandortManager';
export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, onToggleLagerstandort, onDeleteLagerstandort }) {
const { isAdmin, isFilialleiter } = useAuth();
const [showManager, setShowManager] = useState(false);
const counts = {
offen: assets.filter((a) => a.status === 'offen').length,
bearbeitung: assets.filter((a) => a.status === 'in_bearbeitung').length,
entsorgt: assets.filter((a) => a.status === 'entsorgt').length,
overdue: assets.filter(isOverdue).length,
};
return (
<>
<div className="dashboard">
<StatCard color="red" count={counts.offen} label="Offen" />
<StatCard color="yellow" count={counts.bearbeitung} label="In Bearbeitung" />
<StatCard color="gray" count={counts.entsorgt} label="Entsorgt" />
<StatCard color="blue" count={counts.overdue} label="Überfällig (>7 Tage)" />
{(isAdmin || isFilialleiter) && (
<div className="stat-card" style={{ borderColor: '#F57C00', cursor: 'pointer' }} onClick={() => setShowManager(true)}>
<div className="stat-number" style={{ fontSize: '24px' }}>{lagerstandorte.length}</div>
<div className="stat-label">Lagerstandorte verwalten</div>
</div>
)}
</div>
{showManager && (
<LagerstandortManager
lagerstandorte={lagerstandorte}
onAdd={onAddLagerstandort}
onToggle={onToggleLagerstandort}
onDelete={onDeleteLagerstandort}
onClose={() => setShowManager(false)}
/>
)}
</>
);
}
function StatCard({ color, count, label }) {
return (
<div className={`stat-card ${color}`}>
<div className="stat-number">{count}</div>
<div className="stat-label">{label}</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
const EMPTY_FORM = {
erlNummer: '',
seriennummer: '',
artikelNr: '',
bezeichnung: '',
defekt: '',
lagerstandortId: '',
zustaendig: '',
prio: 'mittel',
kommentar: '',
};
export default function DefektForm({ onAdd, showToast, lagerstandorte, colleagues }) {
const { user } = useAuth();
const ownName = user?.name || user?.email || '';
const [form, setForm] = useState({ ...EMPTY_FORM, zustaendig: ownName });
useEffect(() => {
if (ownName && !form.zustaendig) {
setForm((f) => ({ ...f, zustaendig: ownName }));
}
}, [ownName]);
function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(e) {
e.preventDefault();
if (!form.erlNummer.trim() || !form.seriennummer.trim() || !form.zustaendig.trim()) {
showToast('Bitte ERL, Seriennummer und Zuständig ausfüllen!', '#C62828');
return;
}
try {
await onAdd({
erlNummer: form.erlNummer.trim(),
seriennummer: form.seriennummer.trim(),
artikelNr: form.artikelNr.trim(),
bezeichnung: form.bezeichnung.trim(),
defekt: form.defekt.trim(),
lagerstandortId: form.lagerstandortId,
zustaendig: form.zustaendig.trim(),
prio: form.prio,
kommentar: form.kommentar.trim(),
});
showToast('Asset erfasst: ' + form.erlNummer.trim());
setForm({ ...EMPTY_FORM, zustaendig: ownName });
} catch {
showToast('Fehler beim Speichern!', '#C62828');
}
}
return (
<div className="form-card">
<div className="form-header">Defekte Ware erfassen</div>
<form className="form-body" onSubmit={handleSubmit}>
<div className="form-group">
<label>ERL-Nummer (Logistik) *</label>
<input name="erlNummer" value={form.erlNummer} onChange={handleChange} placeholder="z.B. ERL-00001" />
</div>
<div className="form-group">
<label>Seriennummer *</label>
<input name="seriennummer" value={form.seriennummer} onChange={handleChange} placeholder="z.B. SN-ABC123456" />
</div>
<div className="form-group">
<label>Artikelnummer</label>
<input name="artikelNr" value={form.artikelNr} onChange={handleChange} placeholder="z.B. ART-20341" />
</div>
<div className="form-group">
<label>Bezeichnung</label>
<input name="bezeichnung" value={form.bezeichnung} onChange={handleChange} placeholder="z.B. Hydraulikpumpe XL" />
</div>
<div className="form-group">
<label>Defektbeschreibung</label>
<textarea name="defekt" value={form.defekt} onChange={handleChange} placeholder="Was genau ist defekt? Wie sieht der Schaden aus?" />
</div>
<div className="form-group">
<label>Lagerstandort</label>
<select name="lagerstandortId" value={form.lagerstandortId} onChange={handleChange}>
<option value="">-- Standort wählen --</option>
{(lagerstandorte || []).map((ls) => (
<option key={ls.$id} value={ls.$id}>{ls.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Zuständig *</label>
<select name="zustaendig" value={form.zustaendig} onChange={handleChange}>
<option value="">-- Mitarbeiter wählen --</option>
{(colleagues || []).map((c) => (
<option key={c.userId} value={c.userName}>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Priorität *</label>
<select name="prio" value={form.prio} onChange={handleChange}>
<option value="niedrig">Niedrig</option>
<option value="mittel">Mittel</option>
<option value="hoch">Hoch</option>
<option value="kritisch">Kritisch</option>
</select>
</div>
<div className="form-group">
<label>Kommentar</label>
<textarea name="kommentar" value={form.kommentar} onChange={handleChange} placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)" />
</div>
<button type="submit" className="btn-submit">Ware erfassen</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { useAuth } from '../context/AuthContext';
import CommentPopup from './CommentPopup';
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
const STATUS_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
const NEXT_LABEL = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
const PRIO_ORDER = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
const SORT_OPTIONS = [
{ value: 'prio', label: 'Priorität' },
{ value: 'newest', label: 'Neueste zuerst' },
{ value: 'oldest', label: 'Älteste zuerst' },
{ value: 'mine', label: 'Mir zugewiesen' },
];
const STATUS_OPTIONS = [
{ value: 'offen', label: 'Offen' },
{ value: 'in_bearbeitung', label: 'In Bearbeitung' },
{ value: 'entsorgt', label: 'Entsorgt' },
];
function resolveStandortName(asset, lagerstandorte) {
if (!asset.lagerstandortId) return '';
const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId);
return ls ? ls.name : '';
}
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte }) {
const { user } = useAuth();
const navigate = useNavigate();
const [activeFilter, setActiveFilter] = useState(null);
const [commentAsset, setCommentAsset] = useState(null);
const [filters, setFilters] = useState({
erlNummer: '',
artikel: '',
seriennummer: '',
defekt: '',
standort: '',
status: '',
sortBy: 'prio',
});
const setFilter = useCallback((key, value) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const openFilter = useCallback((col) => setActiveFilter(col), []);
const closeFilter = useCallback(() => setActiveFilter(null), []);
const lsMap = useMemo(() => {
const map = {};
(lagerstandorte || []).forEach((l) => { map[l.$id] = l.name; });
return map;
}, [lagerstandorte]);
const filtered = useMemo(() => {
let result = assets.filter((a) => {
if (filters.erlNummer && !(a.erlNummer || '').toLowerCase().includes(filters.erlNummer.toLowerCase())) return false;
if (filters.artikel) {
const q = filters.artikel.toLowerCase();
if (!(a.artikelNr || '').toLowerCase().includes(q) && !(a.bezeichnung || '').toLowerCase().includes(q)) return false;
}
if (filters.seriennummer && !(a.seriennummer || '').toLowerCase().includes(filters.seriennummer.toLowerCase())) return false;
if (filters.defekt && !(a.defekt || '').toLowerCase().includes(filters.defekt.toLowerCase())) return false;
if (filters.standort && a.lagerstandortId !== filters.standort) return false;
if (filters.status && a.status !== filters.status) return false;
return true;
});
if (filters.sortBy === 'mine' && user) {
const userName = (user.name || user.email || '').toLowerCase();
result = result.filter((a) => (a.zustaendig || '').toLowerCase().includes(userName));
}
const getTime = (a) => new Date(a.$createdAt || 0).getTime();
switch (filters.sortBy) {
case 'newest':
result.sort((a, b) => getTime(b) - getTime(a));
break;
case 'oldest':
result.sort((a, b) => getTime(a) - getTime(b));
break;
default:
result.sort((a, b) => (PRIO_ORDER[a.prio] ?? 4) - (PRIO_ORDER[b.prio] ?? 4));
}
return result;
}, [assets, filters, user]);
function handlePrint() {
const printable = filtered.filter((a) => a.status === 'offen' || a.status === 'in_bearbeitung');
if (printable.length === 0) {
showToast('Keine Artikel zum Drucken vorhanden!', '#C62828');
return;
}
const prioColors = { kritisch: '#C62828', hoch: '#F57C00', mittel: '#F9A825', niedrig: '#43A047' };
const prioLabels = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
const rows = printable.map((a) => `
<tr>
<td>${a.erlNummer || ''}</td>
<td style="font-family: monospace;">${a.seriennummer || ''}</td>
<td>${a.defekt || ''}</td>
<td>
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${prioColors[a.prio]};margin-right:6px;"></span>
${prioLabels[a.prio]}
</td>
</tr>
`).join('');
const printHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>DefektTrack - Übersicht</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 30px; color: #333; }
h1 { font-size: 22px; margin-bottom: 5px; color: #1A2B4A; }
.subtitle { font-size: 12px; color: #888; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th { background: #1A2B4A; color: white; padding: 10px 12px; text-align: left; font-size: 12px; text-transform: uppercase; }
td { padding: 10px 12px; border-bottom: 1px solid #ddd; font-size: 13px; }
tr:nth-child(even) { background: #f9f9f9; }
.footer { margin-top: 30px; font-size: 11px; color: #888; text-align: right; }
@media print { body { padding: 15px; } .no-print { display: none; } }
</style>
</head>
<body>
<h1>DefektTrack Defekte Ware Übersicht</h1>
<div class="subtitle">Erstellt am: ${new Date().toLocaleDateString('de-DE')} um ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr · ${printable.length} Artikel</div>
<table>
<thead><tr><th>ERL-Nr.</th><th>Seriennummer</th><th>Defektbeschreibung</th><th>Priorität</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="footer">DefektTrack · Lager &amp; Logistik</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>`;
const printWindow = window.open('', '_blank');
printWindow.document.write(printHTML);
printWindow.document.close();
}
async function handleStatusChange(id) {
try {
await onChangeStatus(id);
} catch {
showToast('Statusänderung fehlgeschlagen!', '#C62828');
}
}
const sortLabel = SORT_OPTIONS.find((o) => o.value === filters.sortBy)?.label || '';
const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
return (
<div className="table-card">
<div className="table-toolbar">
<span className="table-result-count">{filtered.length} Assets</span>
<button className="btn-print-small" onClick={handlePrint} title="Drucken">Drucken</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
<TextFilter value={filters.erlNummer} onChange={(v) => setFilter('erlNummer', v)} placeholder="ERL-Nummer suchen..." />
</ColumnFilter>
<ColumnFilter label="Artikel" active={activeFilter === 'artikel'} summary={filters.artikel || null} onOpen={() => openFilter('artikel')} onClose={closeFilter}>
<TextFilter value={filters.artikel} onChange={(v) => setFilter('artikel', v)} placeholder="Artikelnr. oder Name..." />
</ColumnFilter>
<ColumnFilter label="Seriennr." active={activeFilter === 'seriennummer'} summary={filters.seriennummer || null} onOpen={() => openFilter('seriennummer')} onClose={closeFilter}>
<TextFilter value={filters.seriennummer} onChange={(v) => setFilter('seriennummer', v)} placeholder="Seriennummer suchen..." />
</ColumnFilter>
<ColumnFilter label="Defekt" active={activeFilter === 'defekt'} summary={filters.defekt || null} onOpen={() => openFilter('defekt')} onClose={closeFilter}>
<TextFilter value={filters.defekt} onChange={(v) => setFilter('defekt', v)} placeholder="Defekt suchen..." />
</ColumnFilter>
<ColumnFilter label="Standort" active={activeFilter === 'standort'} summary={filters.standort ? lsMap[filters.standort] : null} onOpen={() => openFilter('standort')} onClose={closeFilter}>
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
</ColumnFilter>
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
</ColumnFilter>
<ColumnFilter label="Suche nach" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
</ColumnFilter>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{filtered.map((a) => {
const days = getDaysOld(a.$createdAt);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
return (
<tr key={a.$id} className={overdue ? 'overdue' : ''}>
<td>
<span className={`prio-badge prio-${a.prio}`} />
<strong style={{ color: '#1565C0' }}>{a.erlNummer || ''}</strong>
</td>
<td>
<strong>{a.artikelNr}</strong><br />
<span style={{ fontSize: '12px', color: '#555' }}>{a.bezeichnung}</span>
</td>
<td style={{ fontSize: '12px', fontFamily: 'monospace' }}>{a.seriennummer || ''}</td>
<td style={{ maxWidth: '180px', fontSize: '12px' }}>{a.defekt}</td>
<td style={{ fontSize: '12px' }}>{resolveStandortName(a, lagerstandorte || [])}</td>
<td>
<span className={`badge badge-${STATUS_MAP[a.status]}`}>{STATUS_LABEL[a.status]}</span>
</td>
<td style={{ fontSize: '12px' }}>
{ageText}
{overdue && <><br /><span className="age-warn">Überfällig!</span></>}
</td>
<td>
<button className="btn-action btn-status" onClick={() => handleStatusChange(a.$id)}>
{NEXT_LABEL[a.status]}
</button>
{a.kommentar && (
<button className="btn-action btn-info" onClick={() => setCommentAsset(a)}>
Info
</button>
)}
<button className="btn-action btn-edit-link" onClick={() => navigate(`/asset/${a.$id}`)}>
Bearbeiten
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{filtered.length === 0 && (
<div className="empty-state">
<div className="emoji">📦</div>
<p>Keine Assets gefunden.</p>
<p style={{ marginTop: '8px' }}>Passe die Filter an oder erfasse ein neues Asset.</p>
</div>
)}
</div>
{commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import Header from './Header';
import Dashboard from './Dashboard';
import DefektForm from './DefektForm';
import DefektTable from './DefektTable';
import Toast from './Toast';
import { useAssets } from '../hooks/useAssets';
import { useAuditLog } from '../hooks/useAuditLog';
import { useLagerstandorte } from '../hooks/useLagerstandorte';
import { useColleagues } from '../hooks/useColleagues';
import { useToast } from '../hooks/useToast';
import { useAuth } from '../context/AuthContext';
export default function DefektTrackApp() {
const { user, userMeta } = useAuth();
const locationId = userMeta?.locationId || '';
const { assets, addAsset, changeStatus } = useAssets();
const { addLog } = useAuditLog();
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
const { colleagues } = useColleagues(locationId);
const { toast, showToast } = useToast();
const userName = user?.name || user?.email || 'Unbekannt';
const handleAdd = useCallback(async (data) => {
const doc = await addAsset({ ...data, createdBy: userName, lastEditedBy: userName });
const assignedTo = data.zustaendig || '';
const isSelf = assignedTo === userName;
const assignInfo = isSelf
? `für sich selbst erfasst`
: `von ${userName} für ${assignedTo} erfasst`;
await addLog({
assetId: doc.$id,
action: 'erstellt',
details: `Asset "${data.erlNummer}" ${assignInfo}`,
userId: user.$id,
userName,
});
return doc;
}, [addAsset, addLog, user, userName]);
const handleStatusChange = useCallback(async (id) => {
const asset = assets.find((a) => a.$id === id);
const oldStatus = asset?.status || '?';
await changeStatus(id);
const statusLabels = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
const nextMap = { offen: 'in_bearbeitung', in_bearbeitung: 'entsorgt', entsorgt: 'offen' };
const newStatus = nextMap[oldStatus] || '?';
await addLog({
assetId: id,
action: 'status_geaendert',
details: `Status: ${statusLabels[oldStatus] || oldStatus}${statusLabels[newStatus] || newStatus}`,
userId: user.$id,
userName,
});
}, [assets, changeStatus, addLog, user, userName]);
return (
<>
<Header assets={assets} showToast={showToast} />
<Dashboard
assets={assets}
lagerstandorte={lagerstandorte}
onAddLagerstandort={addLagerstandort}
onToggleLagerstandort={toggleLagerstandort}
onDeleteLagerstandort={deleteLagerstandort}
/>
<div className="main">
<DefektForm onAdd={handleAdd} showToast={showToast} lagerstandorte={activeLagerstandorte} colleagues={colleagues} />
<DefektTable
assets={assets}
onChangeStatus={handleStatusChange}
showToast={showToast}
lagerstandorte={lagerstandorte}
/>
</div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
);
}

View File

@@ -0,0 +1,214 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
import Toast from './Toast';
import { useToast } from '../hooks/useToast';
import { useAuth } from '../context/AuthContext';
function getToday() {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function getMonthStart() {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function getLastMonthStart() {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth() - 1, 1);
}
function getLastMonthEnd() {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59);
}
function getYesterday() {
const d = getToday();
d.setDate(d.getDate() - 1);
return d;
}
function countInRange(assets, start, end) {
return assets.filter((a) => {
const d = new Date(a.$createdAt);
return d >= start && d <= end;
}).length;
}
export default function FilialleiterDashboard() {
const { userMeta } = useAuth();
const { toast, showToast } = useToast();
const locationId = userMeta?.locationId || '';
const [ownAssets, setOwnAssets] = useState([]);
const [allAssetsTotal, setAllAssetsTotal] = useState(0);
const [allLocationsCount, setAllLocationsCount] = useState(1);
const [colleagues, setColleagues] = useState([]);
const loadData = useCallback(async () => {
if (!locationId) return;
try {
const [assetsRes, metaRes, locsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(500)]),
databases.listDocuments(DATABASE_ID, 'users_meta', [
Query.equal('locationId', [locationId]),
Query.limit(100),
]),
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
]);
setOwnAssets(assetsRes.documents);
setAllAssetsTotal(assetsRes.total);
setAllLocationsCount(Math.max(locsRes.total, 1));
setColleagues(metaRes.documents.filter((d) => d.userName));
} catch (err) {
console.error('Filialleiter-Daten laden fehlgeschlagen:', err);
}
}, [locationId]);
useEffect(() => { loadData(); }, [loadData]);
const now = new Date();
const today = getToday();
const yesterday = getYesterday();
const monthStart = getMonthStart();
const lastMonthStart = getLastMonthStart();
const lastMonthEnd = getLastMonthEnd();
const todayCount = countInRange(ownAssets, today, now);
const yesterdayCount = countInRange(ownAssets, yesterday, today);
const thisMonthCount = countInRange(ownAssets, monthStart, now);
const lastMonthCount = countInRange(ownAssets, lastMonthStart, lastMonthEnd);
const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssetsTotal / allLocationsCount) : 0;
const ownTotal = ownAssets.length;
const employeeStats = useMemo(() => {
return colleagues.map((c) => {
const assigned = ownAssets.filter((a) => a.zustaendig === c.userName);
const resolved = assigned.filter((a) => a.status === 'entsorgt').length;
const open = assigned.filter((a) => a.status === 'offen').length;
const inProgress = assigned.filter((a) => a.status === 'in_bearbeitung').length;
return {
name: c.userName,
total: assigned.length,
resolved,
open,
inProgress,
rate: assigned.length > 0 ? Math.round((resolved / assigned.length) * 100) : 0,
};
}).sort((a, b) => b.rate - a.rate);
}, [colleagues, ownAssets]);
function trendArrow(current, previous) {
if (current > previous) return { arrow: '▲', cls: 'trend-up' };
if (current < previous) return { arrow: '▼', cls: 'trend-down' };
return { arrow: '', cls: 'trend-flat' };
}
const dayTrend = trendArrow(todayCount, yesterdayCount);
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
return (
<>
<Header showToast={showToast} />
<div className="panel-page">
<div className="panel-title-bar">
<h1>Filialleiter Dashboard</h1>
<p>Tägliche und monatliche Übersicht deiner Filiale</p>
</div>
<div className="panel-stats">
<div className="panel-stat-card">
<div className="panel-stat-number">{todayCount}</div>
<div className="panel-stat-label">Heute erfasst</div>
<div className={`panel-trend ${dayTrend.cls}`}>
{dayTrend.arrow} Gestern: {yesterdayCount}
</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{thisMonthCount}</div>
<div className="panel-stat-label">Diesen Monat</div>
<div className={`panel-trend ${monthTrend.cls}`}>
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{ownTotal}</div>
<div className="panel-stat-label">Meine Filiale</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{avgAllFilialen}</div>
<div className="panel-stat-label"> Alle Filialen</div>
</div>
</div>
<div className="panel-comparison">
<h2>Filialvergleich</h2>
<div className="comparison-bars">
<div className="comparison-row">
<span className="comparison-label">Meine Filiale</span>
<div className="comparison-bar-bg">
<div
className="comparison-bar own"
style={{ width: `${Math.min(100, avgAllFilialen > 0 ? (ownTotal / avgAllFilialen) * 50 : 50)}%` }}
/>
</div>
<span className="comparison-value">{ownTotal}</span>
</div>
<div className="comparison-row">
<span className="comparison-label"> Durchschnitt</span>
<div className="comparison-bar-bg">
<div className="comparison-bar avg" style={{ width: '50%' }} />
</div>
<span className="comparison-value">{avgAllFilialen}</span>
</div>
</div>
</div>
<div className="panel-card" style={{ marginTop: 24 }}>
<h2>Mitarbeiter-Performance</h2>
{employeeStats.length === 0 ? (
<p className="panel-empty">Keine Mitarbeiter gefunden</p>
) : (
<div className="employee-table-wrap">
<table className="employee-table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Zugewiesen</th>
<th>Offen</th>
<th>In Bearbeitung</th>
<th>Erledigt</th>
<th>Erledigungsrate</th>
</tr>
</thead>
<tbody>
{employeeStats.map((e) => (
<tr key={e.name}>
<td><strong>{e.name}</strong></td>
<td>{e.total}</td>
<td>{e.open}</td>
<td>{e.inProgress}</td>
<td>{e.resolved}</td>
<td>
<div className="rate-bar-wrap">
<div className="rate-bar" style={{ width: `${e.rate}%` }} />
<span className="rate-text">{e.rate}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
);
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
import Toast from './Toast';
import { useToast } from '../hooks/useToast';
export default function FirmenleiterDashboard() {
const { toast, showToast } = useToast();
const [locations, setLocations] = useState([]);
const [allAssets, setAllAssets] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [allLagerstandorte, setAllLagerstandorte] = useState([]);
const loadData = useCallback(async () => {
try {
const [locsRes, assetsRes, usersRes, lsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(500)]),
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(200)]),
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [Query.limit(200)]),
]);
setLocations(locsRes.documents);
setAllAssets(assetsRes.documents);
setAllUsers(usersRes.documents);
setAllLagerstandorte(lsRes.documents);
} catch (err) {
console.error('Firmenleiter-Daten laden fehlgeschlagen:', err);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const totalAssets = allAssets.length;
const totalOpen = allAssets.filter((a) => a.status === 'offen').length;
const totalInProgress = allAssets.filter((a) => a.status === 'in_bearbeitung').length;
const totalResolved = allAssets.filter((a) => a.status === 'entsorgt').length;
const filialeStats = locations.map((loc) => {
const locUsers = allUsers.filter((u) => u.locationId === loc.$id);
const locLs = allLagerstandorte.filter((l) => l.locationId === loc.$id);
return {
id: loc.$id,
name: loc.name,
address: loc.address || '',
isActive: loc.isActive,
userCount: locUsers.length,
lsCount: locLs.length,
assetsOpen: totalOpen,
assetsInProgress: totalInProgress,
assetsResolved: totalResolved,
assetsTotal: totalAssets,
};
});
return (
<>
<Header showToast={showToast} />
<div className="panel-page">
<div className="panel-title-bar">
<h1>Firmenleiter Dashboard</h1>
<p>Übersicht aller Filialen</p>
</div>
<div className="panel-stats">
<div className="panel-stat-card">
<div className="panel-stat-number">{locations.length}</div>
<div className="panel-stat-label">Filialen</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{allUsers.length}</div>
<div className="panel-stat-label">Mitarbeiter gesamt</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{totalAssets}</div>
<div className="panel-stat-label">Assets gesamt</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}%
</div>
<div className="panel-stat-label">Erledigungsrate</div>
</div>
</div>
<div className="panel-stats" style={{ marginTop: 0 }}>
<div className="panel-stat-card small">
<div className="panel-stat-number" style={{ color: '#C62828' }}>{totalOpen}</div>
<div className="panel-stat-label">Offen</div>
</div>
<div className="panel-stat-card small">
<div className="panel-stat-number" style={{ color: '#F9A825' }}>{totalInProgress}</div>
<div className="panel-stat-label">In Bearbeitung</div>
</div>
<div className="panel-stat-card small">
<div className="panel-stat-number" style={{ color: '#43A047' }}>{totalResolved}</div>
<div className="panel-stat-label">Erledigt</div>
</div>
</div>
<div className="panel-card" style={{ marginTop: 24 }}>
<h2>Alle Filialen</h2>
{filialeStats.length === 0 ? (
<p className="panel-empty">Keine Filialen vorhanden</p>
) : (
<div className="filiale-grid">
{filialeStats.map((f) => (
<div key={f.id} className={`filiale-card ${f.isActive ? '' : 'inactive'}`}>
<div className="filiale-card-header">
<h3>{f.name}</h3>
<span className={`panel-badge ${f.isActive ? 'active' : 'inactive'}`}>
{f.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
{f.address && <p className="filiale-address">{f.address}</p>}
<div className="filiale-stats-row">
<div className="filiale-mini-stat">
<span className="filiale-mini-num">{f.userCount}</span>
<span className="filiale-mini-label">Mitarbeiter</span>
</div>
<div className="filiale-mini-stat">
<span className="filiale-mini-num">{f.lsCount}</span>
<span className="filiale-mini-label">Lagerstandorte</span>
</div>
<div className="filiale-mini-stat">
<span className="filiale-mini-num">{f.assetsTotal}</span>
<span className="filiale-mini-label">Assets</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
);
}

98
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,98 @@
import { useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ROLE_LABELS = {
admin: 'Admin',
firmenleiter: 'Firmenleiter',
filialleiter: 'Filialleiter',
service: 'Service',
lager: 'Lager',
};
export default function Header({ assets, showToast }) {
const fileInputRef = useRef(null);
const { user, role, location, logout, isAdmin, isFilialleiter, isFirmenleiter } = useAuth();
const navigate = useNavigate();
const loc = useLocation();
function handleExport() {
if (!assets || assets.length === 0) {
showToast('Keine Daten zum Exportieren!', '#C62828');
return;
}
const exportObj = {
version: '2.0',
exportedAt: Date.now(),
data: assets,
};
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const now = new Date();
const timestamp = now.toISOString().slice(0, 10) + '-' + now.toTimeString().slice(0, 8).replace(/:/g, '');
a.download = `defekttrack-${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
showToast(`${assets.length} Assets exportiert!`);
}
async function handleLogout() {
try {
await logout();
navigate('/login');
} catch {
showToast?.('Logout fehlgeschlagen', '#C62828');
}
}
const locationName = location?.name || '';
const isOnTracker = loc.pathname === '/tracker' || loc.pathname.startsWith('/asset/');
const isOnAdmin = loc.pathname === '/admin';
const isOnFilialleiter = loc.pathname === '/filialleiter';
const isOnFirmenleiter = loc.pathname === '/firmenleiter';
return (
<header>
<div>
<div className="logo">
Defekt<span>Track</span>
{locationName && <span className="logo-location"> · {locationName}</span>}
</div>
<div className="header-sub">Lager &amp; Logistik · Defekte Ware im Griff by Justin Klein</div>
</div>
<div className="header-buttons">
{user && (
<span className="header-user-info">
{user.name || user.email}
<span className="header-role-badge">{ROLE_LABELS[role] || role}</span>
</span>
)}
<nav className="header-nav">
{!isOnTracker && (
<button className="btn-header btn-nav" onClick={() => navigate('/tracker')}>DefektTrack</button>
)}
{isAdmin && !isOnAdmin && (
<button className="btn-header btn-nav" onClick={() => navigate('/admin')}>Admin Panel</button>
)}
{(isFilialleiter || isAdmin) && !isOnFilialleiter && (
<button className="btn-header btn-nav" onClick={() => navigate('/filialleiter')}>Filialleiter</button>
)}
{(isFirmenleiter || isAdmin) && !isOnFirmenleiter && (
<button className="btn-header btn-nav" onClick={() => navigate('/firmenleiter')}>Firmenleiter</button>
)}
{isOnTracker && assets && (
<button className="btn-header btn-export" onClick={handleExport}>Export</button>
)}
</nav>
<button className="btn-header btn-logout" onClick={handleLogout}>Logout</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
export default function LagerstandortManager({ lagerstandorte, onAdd, onToggle, onDelete, onClose }) {
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
async function handleAdd(e) {
e.preventDefault();
if (!newName.trim()) return;
setAdding(true);
try {
await onAdd(newName.trim());
setNewName('');
} finally {
setAdding(false);
}
}
return (
<>
<div className="comment-overlay" onClick={onClose} />
<div className="comment-popup" style={{ maxWidth: '550px' }}>
<h3>Lagerstandorte verwalten</h3>
<form className="lsm-add-row" onSubmit={handleAdd}>
<input
className="lsm-input"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neuer Standort (z.B. Regal B-12)"
/>
<button type="submit" className="lsm-btn-add" disabled={adding || !newName.trim()}>
{adding ? '...' : 'Hinzufügen'}
</button>
</form>
<div className="lsm-list">
{lagerstandorte.length === 0 && (
<p className="lsm-empty">Noch keine Lagerstandorte angelegt.</p>
)}
{lagerstandorte.map((ls) => (
<div key={ls.$id} className={`lsm-item ${ls.isActive ? '' : 'inactive'}`}>
<span className="lsm-name">{ls.name}</span>
<div className="lsm-actions">
<button
className={`btn-action ${ls.isActive ? 'btn-status' : 'btn-info'}`}
onClick={() => onToggle(ls.$id)}
>
{ls.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button className="btn-action btn-delete" onClick={() => onDelete(ls.$id)}>
Löschen
</button>
</div>
</div>
))}
</div>
<button className="close-btn" onClick={onClose}>Schließen</button>
</div>
</>
);
}

74
src/components/Login.jsx Normal file
View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (!email.trim() || !password.trim()) {
setError('Bitte E-Mail und Passwort eingeben.');
return;
}
setLoading(true);
try {
await login(email.trim(), password);
navigate('/', { replace: true });
} catch (err) {
if (err.code === 401) {
setError('E-Mail oder Passwort falsch.');
} else {
setError('Verbindungsfehler. Bitte erneut versuchen.');
}
} finally {
setLoading(false);
}
}
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<div className="logo">Defekt<span>Track</span></div>
<p className="login-subtitle">Lager &amp; Logistik · Defekte Ware im Griff</p>
</div>
<form className="login-form" onSubmit={handleSubmit}>
<div className="form-group">
<label>E-Mail</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
autoComplete="email"
autoFocus
/>
</div>
<div className="form-group">
<label>Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
autoComplete="current-password"
/>
</div>
{error && <div className="login-error">{error}</div>}
<button type="submit" className="btn-submit" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="login-page">
<div className="login-card" style={{ textAlign: 'center' }}>
<div className="logo" style={{ marginBottom: '12px' }}>Defekt<span>Track</span></div>
<p style={{ color: '#888' }}>Lade...</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}

View File

@@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function RoleRedirect() {
const { role, loading } = useAuth();
if (loading) return null;
if (role === 'admin') return <Navigate to="/admin" replace />;
if (role === 'firmenleiter') return <Navigate to="/firmenleiter" replace />;
if (role === 'filialleiter') return <Navigate to="/filialleiter" replace />;
return <Navigate to="/tracker" replace />;
}

10
src/components/Toast.jsx Normal file
View File

@@ -0,0 +1,10 @@
export default function Toast({ message, color, visible }) {
return (
<div
className={`toast ${visible ? 'show' : ''}`}
style={{ background: color }}
>
{message}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }