feat: initial commit
This commit is contained in:
268
src/components/AdminPanel.jsx
Normal file
268
src/components/AdminPanel.jsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
69
src/components/ColumnFilter.jsx
Normal file
69
src/components/ColumnFilter.jsx
Normal 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' : ''}`}>▾</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>
|
||||
);
|
||||
}
|
||||
22
src/components/CommentPopup.jsx
Normal file
22
src/components/CommentPopup.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/components/Dashboard.jsx
Normal file
52
src/components/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/DefektForm.jsx
Normal file
120
src/components/DefektForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/components/DefektTable.jsx
Normal file
267
src/components/DefektTable.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
80
src/components/DefektTrackApp.jsx
Normal file
80
src/components/DefektTrackApp.jsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
src/components/FilialleiterDashboard.jsx
Normal file
214
src/components/FilialleiterDashboard.jsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
src/components/FirmenleiterDashboard.jsx
Normal file
140
src/components/FirmenleiterDashboard.jsx
Normal 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
98
src/components/Header.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
64
src/components/LagerstandortManager.jsx
Normal file
64
src/components/LagerstandortManager.jsx
Normal 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
74
src/components/Login.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ProtectedRoute.jsx
Normal file
23
src/components/ProtectedRoute.jsx
Normal 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;
|
||||
}
|
||||
13
src/components/RoleRedirect.jsx
Normal file
13
src/components/RoleRedirect.jsx
Normal 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
10
src/components/Toast.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function Toast({ message, color, visible }) {
|
||||
return (
|
||||
<div
|
||||
className={`toast ${visible ? 'show' : ''}`}
|
||||
style={{ background: color }}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/ui/button.jsx
Normal file
57
src/components/ui/button.jsx
Normal 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 }
|
||||
Reference in New Issue
Block a user