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

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/App.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

38
eslint.config.js Normal file
View File

@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View File

@@ -1,690 +1,19 @@
<!DOCTYPE html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DefektTrack Defekte Ware verwalten</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #F4F6FA; color: #333; }
/* HEADER */
header {
background: #1A2B4A;
color: white;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.logo { font-size: 22px; font-weight: 700; }
.logo span { color: #F57C00; }
.header-sub { font-size: 12px; color: #B0C4DE; margin-top: 2px; }
/* DASHBOARD */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
border-top: 4px solid #ccc;
}
.stat-card.red { border-color: #C62828; }
.stat-card.yellow { border-color: #F9A825; }
.stat-card.blue { border-color: #1565C0; }
.stat-card.green { border-color: #2E7D32; }
.stat-card.gray { border-color: #607D8B; }
.stat-number { font-size: 40px; font-weight: 700; color: #1A2B4A; }
.stat-label { font-size: 12px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* MAIN LAYOUT */
.main { max-width: 1200px; margin: 0 auto; padding: 0 24px 40px; display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
@media(max-width: 900px) { .main { grid-template-columns: 1fr; } }
/* FORM */
.form-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
overflow: hidden;
}
.form-header {
background: #1A2B4A;
color: white;
padding: 14px 20px;
font-weight: 600;
font-size: 15px;
}
.form-body { padding: 20px; }
.form-group { margin-bottom: 14px; }
label { display: block; font-size: 12px; font-weight: 600; color: #555; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.4px; }
input, select, textarea {
width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 6px;
font-size: 13px; color: #333; transition: border 0.2s;
font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: #F57C00; }
textarea { resize: vertical; min-height: 70px; }
.btn-submit {
width: 100%; padding: 12px; background: #F57C00; color: white; border: none;
border-radius: 6px; font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 0.2s; margin-top: 6px;
}
.btn-submit:hover { background: #E65100; }
/* TABLE AREA */
.table-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
overflow: hidden;
}
.table-header {
background: #1A2B4A;
color: white;
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.table-title { font-weight: 600; font-size: 15px; }
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-bar input, .filter-bar select {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; padding: 6px 10px; border-radius: 5px; font-size: 12px; width: auto;
}
.filter-bar input::placeholder { color: rgba(255,255,255,0.6); }
.filter-bar select option { color: #333; background: white; }
.btn-print {
background: #F57C00; color: white; border: none; padding: 6px 12px;
border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
.btn-print:hover { background: #E65100; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: #F4F6FA; padding: 10px 12px; text-align: left; font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #eee; }
td { padding: 11px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
tr:hover td { background: #fafbfd; }
tr.overdue td { background: #FFF3E0; }
tr.overdue td:first-child { border-left: 3px solid #F57C00; }
.badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-offen { background: #FFEBEE; color: #C62828; }
.badge-bearbeitung { background: #FFF9C4; color: #F57F17; }
.badge-erledigt { background: #E8F5E9; color: #2E7D32; }
.badge-entsorgt { background: #ECEFF1; color: #607D8B; }
.prio-badge {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px;
}
.prio-kritisch { background: #C62828; }
.prio-hoch { background: #F57C00; }
.prio-mittel { background: #F9A825; }
.prio-niedrig { background: #43A047; }
.btn-action {
padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
border: none; font-weight: 600; margin: 0 2px; transition: opacity 0.15s;
}
.btn-action:hover { opacity: 0.8; }
.btn-status { background: #E3F2FD; color: #1565C0; }
.btn-delete { background: #FFEBEE; color: #C62828; }
.btn-info { background: #F3E5F5; color: #7B1FA2; }
.comment-popup {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: white; padding: 24px; border-radius: 10px; max-width: 500px; width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); z-index: 1000;
}
.comment-popup h3 { margin-bottom: 12px; color: #1A2B4A; font-size: 16px; }
.comment-popup .subject { background: #FFF3E0; color: #E65100; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-weight: 600; }
.comment-popup .text { background: #F5F5F5; padding: 12px; border-radius: 6px; white-space: pre-wrap; font-size: 13px; max-height: 200px; overflow-y: auto; }
.comment-popup .close-btn { margin-top: 16px; width: 100%; padding: 10px; background: #1A2B4A; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.comment-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; }
.empty-state { text-align: center; padding: 60px 20px; color: #aaa; }
.empty-state .emoji { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
.age-warn { font-size: 10px; color: #F57C00; font-weight: 600; }
.toast {
position: fixed; bottom: 24px; right: 24px; background: #2E7D32; color: white;
padding: 12px 20px; border-radius: 6px; font-size: 13px; font-weight: 600;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transform: translateY(100px);
transition: transform 0.3s; z-index: 999;
}
.toast.show { transform: translateY(0); }
.header-buttons { display: flex; gap: 8px; align-items: center; }
.btn-header {
padding: 6px 12px; border-radius: 5px; font-size: 11px; font-weight: 600;
cursor: pointer; border: 1px solid rgba(255,255,255,0.3); transition: all 0.2s;
}
.btn-export { background: #43A047; color: white; }
.btn-export:hover { background: #388E3C; }
.btn-import { background: #1976D2; color: white; }
.btn-import:hover { background: #1565C0; }
</style>
</head>
<body>
<header>
<div>
<div class="logo">Defekt<span>Track</span></div>
<div class="header-sub">Lager & Logistik · Defekte Ware im Griff by Justin Klein</div>
</div>
<div class="header-buttons">
<button class="btn-header btn-export" onclick="exportData()">📤 Export</button>
<button class="btn-header btn-import" onclick="document.getElementById('import-file').click()">📥 Import</button>
<input type="file" id="import-file" accept=".json" style="display:none;" onchange="importData(event)">
</div>
</header>
<!-- DASHBOARD -->
<div class="dashboard">
<div class="stat-card red">
<div class="stat-number" id="cnt-offen">0</div>
<div class="stat-label">🔴 Offen</div>
</div>
<div class="stat-card yellow">
<div class="stat-number" id="cnt-bearbeitung">0</div>
<div class="stat-label">🟡 In Bearbeitung</div>
</div>
<div class="stat-card green">
<div class="stat-number" id="cnt-erledigt">0</div>
<div class="stat-label">🟢 Erledigt</div>
</div>
<div class="stat-card gray">
<div class="stat-number" id="cnt-entsorgt">0</div>
<div class="stat-label">⚫ Entsorgt</div>
</div>
<div class="stat-card blue">
<div class="stat-number" id="cnt-overdue">0</div>
<div class="stat-label">⚠️ Überfällig (&gt;7 Tage)</div>
</div>
</div>
<!-- MAIN -->
<div class="main">
<!-- FORMULAR -->
<div class="form-card">
<div class="form-header"> Defekte Ware erfassen</div>
<div class="form-body">
<div class="form-group">
<label>ERL-Nummer (Logistik) *</label>
<input type="text" id="f-erl" placeholder="z.B. ERL-00001">
</div>
<div class="form-group">
<label>Seriennummer *</label>
<input type="text" id="f-seriennummer" placeholder="z.B. SN-ABC123456">
</div>
<div class="form-group">
<label>Artikelnummer</label>
<input type="text" id="f-artikel" placeholder="z.B. ART-20341">
</div>
<div class="form-group">
<label>Bezeichnung</label>
<input type="text" id="f-bezeichnung" placeholder="z.B. Hydraulikpumpe XL">
</div>
<div class="form-group">
<label>Defektbeschreibung</label>
<textarea id="f-defekt" placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"></textarea>
</div>
<div class="form-group">
<label>Lagerstandort</label>
<input type="text" id="f-standort" placeholder="z.B. Regal B-12 / Halle 3">
</div>
<div class="form-group">
<label>Zuständig *</label>
<input type="text" id="f-zustaendig" placeholder="Name des Mitarbeiters">
</div>
<div class="form-group">
<label>Priorität *</label>
<select id="f-prio">
<option value="niedrig">🟢 Niedrig</option>
<option value="mittel" selected>🟡 Mittel</option>
<option value="hoch">🟠 Hoch</option>
<option value="kritisch">🔴 Kritisch</option>
</select>
</div>
<div class="form-group">
<label>Kommentar</label>
<textarea id="f-kommentar" placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)"></textarea>
</div>
<button class="btn-submit" onclick="addArtikel()">✔ Ware erfassen</button>
</div>
</div>
<!-- TABELLE -->
<div class="table-card">
<div class="table-header">
<div class="table-title">📋 Alle erfassten Artikel</div>
<div class="filter-bar">
<input type="text" id="search" placeholder="🔍 ERL, SN, Artikel, *Betreff*..." oninput="renderTable()">
<select id="filter-status" onchange="renderTable()">
<option value="">Alle Status</option>
<option value="offen">Offen</option>
<option value="in_bearbeitung">In Bearbeitung</option>
<option value="erledigt">Erledigt</option>
<option value="entsorgt">Entsorgt</option>
</select>
<button class="btn-print" onclick="openPrintView()">🖨️ Drucken</button>
</div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>ERL-Nr.</th>
<th>Artikel</th>
<th>Seriennr.</th>
<th>Defekt</th>
<th>Standort</th>
<th>Status</th>
<th>Alter</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
<div class="empty-state" id="empty-state">
<div class="emoji">📦</div>
<p>Noch keine defekten Artikel erfasst.</p>
<p style="margin-top:8px;">Nutze das Formular links um den ersten Artikel einzutragen.</p>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ─── DATEN ───────────────────────────────────
let artikel = JSON.parse(localStorage.getItem('defekttrack') || '[]');
// Demo-Daten beim ersten Start
if (artikel.length === 0) {
const now = Date.now();
artikel = [
{ id: '1', erlNummer: 'ERL-00001', seriennummer: 'SN-HYD2024001', artikelNr: 'ART-1042', bezeichnung: 'Hydraulikpumpe XL', defekt: 'Ölleck am Anschluss', standort: 'Halle B / Regal 3', zustaendig: 'M. Weber', status: 'offen', prio: 'kritisch', kommentar: 'Sofortige Prüfung nötig', erstelltAm: now - 10 * 86400000 },
{ id: '2', erlNummer: 'ERL-00002', seriennummer: 'SN-MOT2024055', artikelNr: 'ART-2210', bezeichnung: 'Förderband-Motor', defekt: 'Lager defekt, lautes Geräusch', standort: 'Lager A', zustaendig: 'S. Klein', status: 'in_bearbeitung', prio: 'hoch', kommentar: '', erstelltAm: now - 4 * 86400000 },
{ id: '3', erlNummer: 'ERL-00003', seriennummer: 'SN-PCB2023189', artikelNr: 'ART-0055', bezeichnung: 'Steuerungsplatine', defekt: 'Kurzschluss nach Wassereinbruch', standort: 'Technikraum', zustaendig: 'T. Braun', status: 'erledigt', prio: 'mittel', kommentar: 'Ersatzteil bestellt', erstelltAm: now - 15 * 86400000 },
{ id: '4', erlNummer: 'ERL-00004', seriennummer: '', artikelNr: 'ART-0891', bezeichnung: 'Gabelstapler-Gabel', defekt: 'Riss in der Schweißnaht', standort: 'Fahrzeughalle', zustaendig: 'K. Müller', status: 'offen', prio: 'hoch', kommentar: '', erstelltAm: now - 2 * 86400000 },
{ id: '5', erlNummer: 'ERL-00005', seriennummer: 'SN-SCH2022044', artikelNr: 'ART-3300', bezeichnung: 'Lagerschiene Typ A', defekt: 'Verformt, nicht mehr nutzbar', standort: 'Regal C-08', zustaendig: '', status: 'entsorgt', prio: 'niedrig', kommentar: 'Wurde entsorgt', erstelltAm: now - 20 * 86400000 },
];
save();
}
function save() {
localStorage.setItem('defekttrack', JSON.stringify(artikel));
}
function getDaysOld(ts) {
return Math.floor((Date.now() - ts) / 86400000);
}
function isOverdue(a) {
return (a.status === 'offen' || a.status === 'in_bearbeitung') && getDaysOld(a.erstelltAm) > 7;
}
// ─── DASHBOARD ───────────────────────────────
function updateDashboard() {
document.getElementById('cnt-offen').textContent = artikel.filter(a => a.status === 'offen').length;
document.getElementById('cnt-bearbeitung').textContent = artikel.filter(a => a.status === 'in_bearbeitung').length;
document.getElementById('cnt-erledigt').textContent = artikel.filter(a => a.status === 'erledigt').length;
document.getElementById('cnt-entsorgt').textContent = artikel.filter(a => a.status === 'entsorgt').length;
document.getElementById('cnt-overdue').textContent = artikel.filter(isOverdue).length;
}
// ─── TABELLE ─────────────────────────────────
function renderTable() {
const search = document.getElementById('search').value.toLowerCase();
const filterStatus = document.getElementById('filter-status').value;
let filtered = artikel.filter(a => {
const matchSearch = !search ||
(a.erlNummer || '').toLowerCase().includes(search) ||
(a.seriennummer || '').toLowerCase().includes(search) ||
(a.artikelNr || '').toLowerCase().includes(search) ||
(a.bezeichnung || '').toLowerCase().includes(search) ||
(a.defekt || '').toLowerCase().includes(search) ||
(a.zustaendig || '').toLowerCase().includes(search) ||
(a.kommentar || '').toLowerCase().includes(search);
const matchStatus = !filterStatus || a.status === filterStatus;
return matchSearch && matchStatus;
});
const prioOrder = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
filtered.sort((a, b) => (prioOrder[a.prio] ?? 4) - (prioOrder[b.prio] ?? 4));
const tbody = document.getElementById('table-body');
const emptyState = document.getElementById('empty-state');
if (filtered.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
const statusMap = { offen: 'offen', in_bearbeitung: 'bearbeitung', erledigt: 'erledigt', entsorgt: 'entsorgt' };
const statusLabel = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', entsorgt: 'Entsorgt' };
const nextStatus = { offen: 'in_bearbeitung', in_bearbeitung: 'erledigt', erledigt: 'entsorgt', entsorgt: 'offen' };
const nextLabel = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Erledigt', erledigt: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
tbody.innerHTML = filtered.map(a => {
const days = getDaysOld(a.erstelltAm);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
const ageWarn = overdue ? `<br><span class="age-warn">⚠ Überfällig!</span>` : '';
const prioClass = 'prio-' + a.prio;
return `<tr class="${overdue ? 'overdue' : ''}">
<td>
<span class="prio-badge ${prioClass}"></span>
<strong style="color:#1565C0;">${a.erlNummer || ''}</strong>
</td>
<td>
<strong>${a.artikelNr}</strong><br>
<span style="font-size:12px;color:#555;">${a.bezeichnung}</span>
</td>
<td style="font-size:12px;font-family:monospace;">${a.seriennummer || ''}</td>
<td style="max-width:180px;font-size:12px;">${a.defekt}</td>
<td style="font-size:12px;">${a.standort || ''}</td>
<td><span class="badge badge-${statusMap[a.status]}">${statusLabel[a.status]}</span></td>
<td style="font-size:12px;">${ageText}${ageWarn}</td>
<td>
<button class="btn-action btn-status" onclick="changeStatus('${a.id}')">${nextLabel[a.status]}</button>
${a.kommentar ? `<button class="btn-action btn-info" onclick="showComment('${a.id}')">💬</button>` : ''}
<button class="btn-action btn-delete" onclick="deleteArtikel('${a.id}')">🗑</button>
</td>
</tr>`;
}).join('');
updateDashboard();
}
// ─── AKTIONEN ────────────────────────────────
function addArtikel() {
const erlNummer = document.getElementById('f-erl').value.trim();
const seriennummer = document.getElementById('f-seriennummer').value.trim();
const zustaendig = document.getElementById('f-zustaendig').value.trim();
if (!erlNummer || !seriennummer || !zustaendig) {
showToast('⚠️ Bitte ERL, Seriennummer und Zuständig ausfüllen!', '#C62828');
return;
}
const newItem = {
id: Date.now().toString(),
erlNummer,
seriennummer,
artikelNr: document.getElementById('f-artikel').value.trim(),
bezeichnung: document.getElementById('f-bezeichnung').value.trim(),
defekt: document.getElementById('f-defekt').value.trim(),
standort: document.getElementById('f-standort').value.trim(),
zustaendig,
prio: document.getElementById('f-prio').value,
kommentar: document.getElementById('f-kommentar').value.trim(),
status: 'offen',
erstelltAm: Date.now(),
};
artikel.unshift(newItem);
save();
renderTable();
showToast('✅ Artikel erfasst: ' + erlNummer);
// Felder leeren
['f-erl','f-seriennummer','f-artikel','f-bezeichnung','f-defekt','f-standort','f-zustaendig','f-kommentar'].forEach(id => {
document.getElementById(id).value = '';
});
}
function changeStatus(id) {
const nextStatus = { offen: 'in_bearbeitung', in_bearbeitung: 'erledigt', erledigt: 'entsorgt', entsorgt: 'offen' };
const a = artikel.find(x => x.id === id);
if (a) {
a.status = nextStatus[a.status];
save();
renderTable();
showToast('Status geändert → ' + a.status.replace('_', ' '));
}
}
function deleteArtikel(id) {
if (!confirm('Diesen Artikel wirklich löschen?')) return;
artikel = artikel.filter(x => x.id !== id);
save();
renderTable();
showToast('🗑 Artikel gelöscht', '#607D8B');
}
function showComment(id) {
const a = artikel.find(x => x.id === id);
if (!a || !a.kommentar) return;
let subject = '';
let text = a.kommentar;
const match = a.kommentar.match(/^\*([^*]+)\*/);
if (match) {
subject = match[1].trim();
text = a.kommentar.substring(match[0].length).trim();
}
const overlay = document.createElement('div');
overlay.className = 'comment-overlay';
overlay.onclick = () => { overlay.remove(); popup.remove(); };
const popup = document.createElement('div');
popup.className = 'comment-popup';
popup.innerHTML = `
<h3>💬 Kommentar zu ${a.erlNummer}</h3>
${subject ? `<div class="subject">📧 ${subject}</div>` : ''}
<div class="text">${text || '(Kein weiterer Kommentar)'}</div>
<button class="close-btn" onclick="this.parentElement.remove(); document.querySelector('.comment-overlay').remove();">Schließen</button>
`;
document.body.appendChild(overlay);
document.body.appendChild(popup);
}
function openPrintView() {
const search = document.getElementById('search').value.toLowerCase();
let filtered = artikel.filter(a => {
const isActive = a.status === 'offen' || a.status === 'in_bearbeitung';
const matchSearch = !search ||
(a.erlNummer || '').toLowerCase().includes(search) ||
(a.seriennummer || '').toLowerCase().includes(search) ||
(a.artikelNr || '').toLowerCase().includes(search) ||
(a.bezeichnung || '').toLowerCase().includes(search) ||
(a.defekt || '').toLowerCase().includes(search) ||
(a.zustaendig || '').toLowerCase().includes(search) ||
(a.kommentar || '').toLowerCase().includes(search);
return isActive && matchSearch;
});
const prioOrder = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
filtered.sort((a, b) => (prioOrder[a.prio] ?? 4) - (prioOrder[b.prio] ?? 4));
if (filtered.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 = filtered.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 · ${filtered.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>
<scr` + `ipt>window.onload = function() { window.print(); }</scr` + `ipt>
</body>
</html>
`;
const printWindow = window.open('', '_blank');
printWindow.document.write(printHTML);
printWindow.document.close();
}
// ─── EXPORT / IMPORT ─────────────────────────
function exportData() {
if (artikel.length === 0) {
showToast('⚠️ Keine Daten zum Exportieren!', '#C62828');
return;
}
const exportObj = {
version: '1.0',
exportedAt: Date.now(),
data: artikel
};
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(`📤 ${artikel.length} Einträge exportiert!`);
}
function importData(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const imported = JSON.parse(e.target.result);
const importedData = imported.data || imported;
if (!Array.isArray(importedData)) {
showToast('⚠️ Ungültiges Dateiformat!', '#C62828');
return;
}
let added = 0, updated = 0, kept = 0;
importedData.forEach(item => {
const existing = artikel.find(a => a.id === item.id);
if (!existing) {
artikel.push(item);
added++;
} else {
const existingTime = existing.erstelltAm || 0;
const importedTime = item.erstelltAm || 0;
if (importedTime > existingTime) {
Object.assign(existing, item);
updated++;
} else {
kept++;
}
}
});
artikel.sort((a, b) => (b.erstelltAm || 0) - (a.erstelltAm || 0));
save();
renderTable();
showToast(`📥 Import: +${added} neu, ${updated} aktualisiert, ${kept} unverändert`);
} catch (err) {
showToast('⚠️ Fehler beim Lesen der Datei!', '#C62828');
}
};
reader.readAsText(file);
event.target.value = '';
}
function showToast(msg, color = '#2E7D32') {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.background = color;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
// Start
renderTable();
updateDashboard();
</script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/appwrite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DefektTrack Defekte Ware verwalten</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

690
index.html.bak Normal file
View File

@@ -0,0 +1,690 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DefektTrack Defekte Ware verwalten</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #F4F6FA; color: #333; }
/* HEADER */
header {
background: #1A2B4A;
color: white;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.logo { font-size: 22px; font-weight: 700; }
.logo span { color: #F57C00; }
.header-sub { font-size: 12px; color: #B0C4DE; margin-top: 2px; }
/* DASHBOARD */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
border-top: 4px solid #ccc;
}
.stat-card.red { border-color: #C62828; }
.stat-card.yellow { border-color: #F9A825; }
.stat-card.blue { border-color: #1565C0; }
.stat-card.green { border-color: #2E7D32; }
.stat-card.gray { border-color: #607D8B; }
.stat-number { font-size: 40px; font-weight: 700; color: #1A2B4A; }
.stat-label { font-size: 12px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* MAIN LAYOUT */
.main { max-width: 1200px; margin: 0 auto; padding: 0 24px 40px; display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
@media(max-width: 900px) { .main { grid-template-columns: 1fr; } }
/* FORM */
.form-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
overflow: hidden;
}
.form-header {
background: #1A2B4A;
color: white;
padding: 14px 20px;
font-weight: 600;
font-size: 15px;
}
.form-body { padding: 20px; }
.form-group { margin-bottom: 14px; }
label { display: block; font-size: 12px; font-weight: 600; color: #555; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.4px; }
input, select, textarea {
width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 6px;
font-size: 13px; color: #333; transition: border 0.2s;
font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: #F57C00; }
textarea { resize: vertical; min-height: 70px; }
.btn-submit {
width: 100%; padding: 12px; background: #F57C00; color: white; border: none;
border-radius: 6px; font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 0.2s; margin-top: 6px;
}
.btn-submit:hover { background: #E65100; }
/* TABLE AREA */
.table-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
overflow: hidden;
}
.table-header {
background: #1A2B4A;
color: white;
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.table-title { font-weight: 600; font-size: 15px; }
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-bar input, .filter-bar select {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; padding: 6px 10px; border-radius: 5px; font-size: 12px; width: auto;
}
.filter-bar input::placeholder { color: rgba(255,255,255,0.6); }
.filter-bar select option { color: #333; background: white; }
.btn-print {
background: #F57C00; color: white; border: none; padding: 6px 12px;
border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
.btn-print:hover { background: #E65100; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: #F4F6FA; padding: 10px 12px; text-align: left; font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #eee; }
td { padding: 11px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
tr:hover td { background: #fafbfd; }
tr.overdue td { background: #FFF3E0; }
tr.overdue td:first-child { border-left: 3px solid #F57C00; }
.badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-offen { background: #FFEBEE; color: #C62828; }
.badge-bearbeitung { background: #FFF9C4; color: #F57F17; }
.badge-erledigt { background: #E8F5E9; color: #2E7D32; }
.badge-entsorgt { background: #ECEFF1; color: #607D8B; }
.prio-badge {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px;
}
.prio-kritisch { background: #C62828; }
.prio-hoch { background: #F57C00; }
.prio-mittel { background: #F9A825; }
.prio-niedrig { background: #43A047; }
.btn-action {
padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
border: none; font-weight: 600; margin: 0 2px; transition: opacity 0.15s;
}
.btn-action:hover { opacity: 0.8; }
.btn-status { background: #E3F2FD; color: #1565C0; }
.btn-delete { background: #FFEBEE; color: #C62828; }
.btn-info { background: #F3E5F5; color: #7B1FA2; }
.comment-popup {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: white; padding: 24px; border-radius: 10px; max-width: 500px; width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); z-index: 1000;
}
.comment-popup h3 { margin-bottom: 12px; color: #1A2B4A; font-size: 16px; }
.comment-popup .subject { background: #FFF3E0; color: #E65100; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-weight: 600; }
.comment-popup .text { background: #F5F5F5; padding: 12px; border-radius: 6px; white-space: pre-wrap; font-size: 13px; max-height: 200px; overflow-y: auto; }
.comment-popup .close-btn { margin-top: 16px; width: 100%; padding: 10px; background: #1A2B4A; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.comment-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; }
.empty-state { text-align: center; padding: 60px 20px; color: #aaa; }
.empty-state .emoji { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
.age-warn { font-size: 10px; color: #F57C00; font-weight: 600; }
.toast {
position: fixed; bottom: 24px; right: 24px; background: #2E7D32; color: white;
padding: 12px 20px; border-radius: 6px; font-size: 13px; font-weight: 600;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transform: translateY(100px);
transition: transform 0.3s; z-index: 999;
}
.toast.show { transform: translateY(0); }
.header-buttons { display: flex; gap: 8px; align-items: center; }
.btn-header {
padding: 6px 12px; border-radius: 5px; font-size: 11px; font-weight: 600;
cursor: pointer; border: 1px solid rgba(255,255,255,0.3); transition: all 0.2s;
}
.btn-export { background: #43A047; color: white; }
.btn-export:hover { background: #388E3C; }
.btn-import { background: #1976D2; color: white; }
.btn-import:hover { background: #1565C0; }
</style>
</head>
<body>
<header>
<div>
<div class="logo">Defekt<span>Track</span></div>
<div class="header-sub">Lager & Logistik · Defekte Ware im Griff by Justin Klein</div>
</div>
<div class="header-buttons">
<button class="btn-header btn-export" onclick="exportData()">📤 Export</button>
<button class="btn-header btn-import" onclick="document.getElementById('import-file').click()">📥 Import</button>
<input type="file" id="import-file" accept=".json" style="display:none;" onchange="importData(event)">
</div>
</header>
<!-- DASHBOARD -->
<div class="dashboard">
<div class="stat-card red">
<div class="stat-number" id="cnt-offen">0</div>
<div class="stat-label">🔴 Offen</div>
</div>
<div class="stat-card yellow">
<div class="stat-number" id="cnt-bearbeitung">0</div>
<div class="stat-label">🟡 In Bearbeitung</div>
</div>
<div class="stat-card green">
<div class="stat-number" id="cnt-erledigt">0</div>
<div class="stat-label">🟢 Erledigt</div>
</div>
<div class="stat-card gray">
<div class="stat-number" id="cnt-entsorgt">0</div>
<div class="stat-label">⚫ Entsorgt</div>
</div>
<div class="stat-card blue">
<div class="stat-number" id="cnt-overdue">0</div>
<div class="stat-label">⚠️ Überfällig (&gt;7 Tage)</div>
</div>
</div>
<!-- MAIN -->
<div class="main">
<!-- FORMULAR -->
<div class="form-card">
<div class="form-header"> Defekte Ware erfassen</div>
<div class="form-body">
<div class="form-group">
<label>ERL-Nummer (Logistik) *</label>
<input type="text" id="f-erl" placeholder="z.B. ERL-00001">
</div>
<div class="form-group">
<label>Seriennummer *</label>
<input type="text" id="f-seriennummer" placeholder="z.B. SN-ABC123456">
</div>
<div class="form-group">
<label>Artikelnummer</label>
<input type="text" id="f-artikel" placeholder="z.B. ART-20341">
</div>
<div class="form-group">
<label>Bezeichnung</label>
<input type="text" id="f-bezeichnung" placeholder="z.B. Hydraulikpumpe XL">
</div>
<div class="form-group">
<label>Defektbeschreibung</label>
<textarea id="f-defekt" placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"></textarea>
</div>
<div class="form-group">
<label>Lagerstandort</label>
<input type="text" id="f-standort" placeholder="z.B. Regal B-12 / Halle 3">
</div>
<div class="form-group">
<label>Zuständig *</label>
<input type="text" id="f-zustaendig" placeholder="Name des Mitarbeiters">
</div>
<div class="form-group">
<label>Priorität *</label>
<select id="f-prio">
<option value="niedrig">🟢 Niedrig</option>
<option value="mittel" selected>🟡 Mittel</option>
<option value="hoch">🟠 Hoch</option>
<option value="kritisch">🔴 Kritisch</option>
</select>
</div>
<div class="form-group">
<label>Kommentar</label>
<textarea id="f-kommentar" placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)"></textarea>
</div>
<button class="btn-submit" onclick="addArtikel()">✔ Ware erfassen</button>
</div>
</div>
<!-- TABELLE -->
<div class="table-card">
<div class="table-header">
<div class="table-title">📋 Alle erfassten Artikel</div>
<div class="filter-bar">
<input type="text" id="search" placeholder="🔍 ERL, SN, Artikel, *Betreff*..." oninput="renderTable()">
<select id="filter-status" onchange="renderTable()">
<option value="">Alle Status</option>
<option value="offen">Offen</option>
<option value="in_bearbeitung">In Bearbeitung</option>
<option value="erledigt">Erledigt</option>
<option value="entsorgt">Entsorgt</option>
</select>
<button class="btn-print" onclick="openPrintView()">🖨️ Drucken</button>
</div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>ERL-Nr.</th>
<th>Artikel</th>
<th>Seriennr.</th>
<th>Defekt</th>
<th>Standort</th>
<th>Status</th>
<th>Alter</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
<div class="empty-state" id="empty-state">
<div class="emoji">📦</div>
<p>Noch keine defekten Artikel erfasst.</p>
<p style="margin-top:8px;">Nutze das Formular links um den ersten Artikel einzutragen.</p>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ─── DATEN ───────────────────────────────────
let artikel = JSON.parse(localStorage.getItem('defekttrack') || '[]');
// Demo-Daten beim ersten Start
if (artikel.length === 0) {
const now = Date.now();
artikel = [
{ id: '1', erlNummer: 'ERL-00001', seriennummer: 'SN-HYD2024001', artikelNr: 'ART-1042', bezeichnung: 'Hydraulikpumpe XL', defekt: 'Ölleck am Anschluss', standort: 'Halle B / Regal 3', zustaendig: 'M. Weber', status: 'offen', prio: 'kritisch', kommentar: 'Sofortige Prüfung nötig', erstelltAm: now - 10 * 86400000 },
{ id: '2', erlNummer: 'ERL-00002', seriennummer: 'SN-MOT2024055', artikelNr: 'ART-2210', bezeichnung: 'Förderband-Motor', defekt: 'Lager defekt, lautes Geräusch', standort: 'Lager A', zustaendig: 'S. Klein', status: 'in_bearbeitung', prio: 'hoch', kommentar: '', erstelltAm: now - 4 * 86400000 },
{ id: '3', erlNummer: 'ERL-00003', seriennummer: 'SN-PCB2023189', artikelNr: 'ART-0055', bezeichnung: 'Steuerungsplatine', defekt: 'Kurzschluss nach Wassereinbruch', standort: 'Technikraum', zustaendig: 'T. Braun', status: 'erledigt', prio: 'mittel', kommentar: 'Ersatzteil bestellt', erstelltAm: now - 15 * 86400000 },
{ id: '4', erlNummer: 'ERL-00004', seriennummer: '', artikelNr: 'ART-0891', bezeichnung: 'Gabelstapler-Gabel', defekt: 'Riss in der Schweißnaht', standort: 'Fahrzeughalle', zustaendig: 'K. Müller', status: 'offen', prio: 'hoch', kommentar: '', erstelltAm: now - 2 * 86400000 },
{ id: '5', erlNummer: 'ERL-00005', seriennummer: 'SN-SCH2022044', artikelNr: 'ART-3300', bezeichnung: 'Lagerschiene Typ A', defekt: 'Verformt, nicht mehr nutzbar', standort: 'Regal C-08', zustaendig: '', status: 'entsorgt', prio: 'niedrig', kommentar: 'Wurde entsorgt', erstelltAm: now - 20 * 86400000 },
];
save();
}
function save() {
localStorage.setItem('defekttrack', JSON.stringify(artikel));
}
function getDaysOld(ts) {
return Math.floor((Date.now() - ts) / 86400000);
}
function isOverdue(a) {
return (a.status === 'offen' || a.status === 'in_bearbeitung') && getDaysOld(a.erstelltAm) > 7;
}
// ─── DASHBOARD ───────────────────────────────
function updateDashboard() {
document.getElementById('cnt-offen').textContent = artikel.filter(a => a.status === 'offen').length;
document.getElementById('cnt-bearbeitung').textContent = artikel.filter(a => a.status === 'in_bearbeitung').length;
document.getElementById('cnt-erledigt').textContent = artikel.filter(a => a.status === 'erledigt').length;
document.getElementById('cnt-entsorgt').textContent = artikel.filter(a => a.status === 'entsorgt').length;
document.getElementById('cnt-overdue').textContent = artikel.filter(isOverdue).length;
}
// ─── TABELLE ─────────────────────────────────
function renderTable() {
const search = document.getElementById('search').value.toLowerCase();
const filterStatus = document.getElementById('filter-status').value;
let filtered = artikel.filter(a => {
const matchSearch = !search ||
(a.erlNummer || '').toLowerCase().includes(search) ||
(a.seriennummer || '').toLowerCase().includes(search) ||
(a.artikelNr || '').toLowerCase().includes(search) ||
(a.bezeichnung || '').toLowerCase().includes(search) ||
(a.defekt || '').toLowerCase().includes(search) ||
(a.zustaendig || '').toLowerCase().includes(search) ||
(a.kommentar || '').toLowerCase().includes(search);
const matchStatus = !filterStatus || a.status === filterStatus;
return matchSearch && matchStatus;
});
const prioOrder = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
filtered.sort((a, b) => (prioOrder[a.prio] ?? 4) - (prioOrder[b.prio] ?? 4));
const tbody = document.getElementById('table-body');
const emptyState = document.getElementById('empty-state');
if (filtered.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
const statusMap = { offen: 'offen', in_bearbeitung: 'bearbeitung', erledigt: 'erledigt', entsorgt: 'entsorgt' };
const statusLabel = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', entsorgt: 'Entsorgt' };
const nextStatus = { offen: 'in_bearbeitung', in_bearbeitung: 'erledigt', erledigt: 'entsorgt', entsorgt: 'offen' };
const nextLabel = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Erledigt', erledigt: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
tbody.innerHTML = filtered.map(a => {
const days = getDaysOld(a.erstelltAm);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
const ageWarn = overdue ? `<br><span class="age-warn">⚠ Überfällig!</span>` : '';
const prioClass = 'prio-' + a.prio;
return `<tr class="${overdue ? 'overdue' : ''}">
<td>
<span class="prio-badge ${prioClass}"></span>
<strong style="color:#1565C0;">${a.erlNummer || ''}</strong>
</td>
<td>
<strong>${a.artikelNr}</strong><br>
<span style="font-size:12px;color:#555;">${a.bezeichnung}</span>
</td>
<td style="font-size:12px;font-family:monospace;">${a.seriennummer || ''}</td>
<td style="max-width:180px;font-size:12px;">${a.defekt}</td>
<td style="font-size:12px;">${a.standort || ''}</td>
<td><span class="badge badge-${statusMap[a.status]}">${statusLabel[a.status]}</span></td>
<td style="font-size:12px;">${ageText}${ageWarn}</td>
<td>
<button class="btn-action btn-status" onclick="changeStatus('${a.id}')">${nextLabel[a.status]}</button>
${a.kommentar ? `<button class="btn-action btn-info" onclick="showComment('${a.id}')">💬</button>` : ''}
<button class="btn-action btn-delete" onclick="deleteArtikel('${a.id}')">🗑</button>
</td>
</tr>`;
}).join('');
updateDashboard();
}
// ─── AKTIONEN ────────────────────────────────
function addArtikel() {
const erlNummer = document.getElementById('f-erl').value.trim();
const seriennummer = document.getElementById('f-seriennummer').value.trim();
const zustaendig = document.getElementById('f-zustaendig').value.trim();
if (!erlNummer || !seriennummer || !zustaendig) {
showToast('⚠️ Bitte ERL, Seriennummer und Zuständig ausfüllen!', '#C62828');
return;
}
const newItem = {
id: Date.now().toString(),
erlNummer,
seriennummer,
artikelNr: document.getElementById('f-artikel').value.trim(),
bezeichnung: document.getElementById('f-bezeichnung').value.trim(),
defekt: document.getElementById('f-defekt').value.trim(),
standort: document.getElementById('f-standort').value.trim(),
zustaendig,
prio: document.getElementById('f-prio').value,
kommentar: document.getElementById('f-kommentar').value.trim(),
status: 'offen',
erstelltAm: Date.now(),
};
artikel.unshift(newItem);
save();
renderTable();
showToast('✅ Artikel erfasst: ' + erlNummer);
// Felder leeren
['f-erl','f-seriennummer','f-artikel','f-bezeichnung','f-defekt','f-standort','f-zustaendig','f-kommentar'].forEach(id => {
document.getElementById(id).value = '';
});
}
function changeStatus(id) {
const nextStatus = { offen: 'in_bearbeitung', in_bearbeitung: 'erledigt', erledigt: 'entsorgt', entsorgt: 'offen' };
const a = artikel.find(x => x.id === id);
if (a) {
a.status = nextStatus[a.status];
save();
renderTable();
showToast('Status geändert → ' + a.status.replace('_', ' '));
}
}
function deleteArtikel(id) {
if (!confirm('Diesen Artikel wirklich löschen?')) return;
artikel = artikel.filter(x => x.id !== id);
save();
renderTable();
showToast('🗑 Artikel gelöscht', '#607D8B');
}
function showComment(id) {
const a = artikel.find(x => x.id === id);
if (!a || !a.kommentar) return;
let subject = '';
let text = a.kommentar;
const match = a.kommentar.match(/^\*([^*]+)\*/);
if (match) {
subject = match[1].trim();
text = a.kommentar.substring(match[0].length).trim();
}
const overlay = document.createElement('div');
overlay.className = 'comment-overlay';
overlay.onclick = () => { overlay.remove(); popup.remove(); };
const popup = document.createElement('div');
popup.className = 'comment-popup';
popup.innerHTML = `
<h3>💬 Kommentar zu ${a.erlNummer}</h3>
${subject ? `<div class="subject">📧 ${subject}</div>` : ''}
<div class="text">${text || '(Kein weiterer Kommentar)'}</div>
<button class="close-btn" onclick="this.parentElement.remove(); document.querySelector('.comment-overlay').remove();">Schließen</button>
`;
document.body.appendChild(overlay);
document.body.appendChild(popup);
}
function openPrintView() {
const search = document.getElementById('search').value.toLowerCase();
let filtered = artikel.filter(a => {
const isActive = a.status === 'offen' || a.status === 'in_bearbeitung';
const matchSearch = !search ||
(a.erlNummer || '').toLowerCase().includes(search) ||
(a.seriennummer || '').toLowerCase().includes(search) ||
(a.artikelNr || '').toLowerCase().includes(search) ||
(a.bezeichnung || '').toLowerCase().includes(search) ||
(a.defekt || '').toLowerCase().includes(search) ||
(a.zustaendig || '').toLowerCase().includes(search) ||
(a.kommentar || '').toLowerCase().includes(search);
return isActive && matchSearch;
});
const prioOrder = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
filtered.sort((a, b) => (prioOrder[a.prio] ?? 4) - (prioOrder[b.prio] ?? 4));
if (filtered.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 = filtered.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 · ${filtered.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>
<scr` + `ipt>window.onload = function() { window.print(); }</scr` + `ipt>
</body>
</html>
`;
const printWindow = window.open('', '_blank');
printWindow.document.write(printHTML);
printWindow.document.close();
}
// ─── EXPORT / IMPORT ─────────────────────────
function exportData() {
if (artikel.length === 0) {
showToast('⚠️ Keine Daten zum Exportieren!', '#C62828');
return;
}
const exportObj = {
version: '1.0',
exportedAt: Date.now(),
data: artikel
};
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(`📤 ${artikel.length} Einträge exportiert!`);
}
function importData(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const imported = JSON.parse(e.target.result);
const importedData = imported.data || imported;
if (!Array.isArray(importedData)) {
showToast('⚠️ Ungültiges Dateiformat!', '#C62828');
return;
}
let added = 0, updated = 0, kept = 0;
importedData.forEach(item => {
const existing = artikel.find(a => a.id === item.id);
if (!existing) {
artikel.push(item);
added++;
} else {
const existingTime = existing.erstelltAm || 0;
const importedTime = item.erstelltAm || 0;
if (importedTime > existingTime) {
Object.assign(existing, item);
updated++;
} else {
kept++;
}
}
});
artikel.sort((a, b) => (b.erstelltAm || 0) - (a.erstelltAm || 0));
save();
renderTable();
showToast(`📥 Import: +${added} neu, ${updated} aktualisiert, ${kept} unverändert`);
} catch (err) {
showToast('⚠️ Fehler beim Lesen der Datei!', '#C62828');
}
};
reader.readAsText(file);
event.target.value = '';
}
function showToast(msg, color = '#2E7D32') {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.background = color;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
// Start
renderTable();
updateDashboard();
</script>
</body>
</html>

8
jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

8555
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "defekttrack",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"setup": "node scripts/setup-appwrite.js"
},
"dependencies": {
"@appwrite.io/pink-icons": "^1.0.0",
"@base-ui/react": "^1.2.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.0.14",
"appwrite": "^21.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shadcn": "^4.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"node-appwrite": "^22.1.3",
"prettier": "3.5.3",
"vite": "^6.1.0"
}
}

8
public/appwrite.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M24.4429 16.4322V21.9096H10.7519C6.76318 21.9096 3.28044 19.7067 1.4171 16.4322C1.14622 15.9561 0.909137 15.4567 0.710264 14.9383C0.319864 13.9225 0.0744552 12.8325 0 11.6952V10.2143C0.0161646 9.96089 0.0416361 9.70942 0.0749451 9.46095C0.143032 8.95105 0.245898 8.45211 0.381093 7.96711C1.66006 3.36909 5.81877 0 10.7519 0C15.6851 0 19.8433 3.36909 21.1223 7.96711H15.2682C14.3072 6.4683 12.6437 5.4774 10.7519 5.4774C8.86017 5.4774 7.19668 6.4683 6.23562 7.96711C5.9427 8.42274 5.71542 8.92516 5.56651 9.46095C5.43425 9.93599 5.36371 10.4369 5.36371 10.9548C5.36371 12.5248 6.01324 13.94 7.05463 14.9383C8.01961 15.865 9.32061 16.4322 10.7519 16.4322H24.4429Z"
fill="#FD366E" />
<path
d="M24.4429 9.46094V14.9383H14.4492C15.4906 13.94 16.1401 12.5248 16.1401 10.9548C16.1401 10.4369 16.0696 9.93598 15.9373 9.46094H24.4429Z"
fill="#FD366E" />
</svg>

After

Width:  |  Height:  |  Size: 1012 B

361
scripts/setup-appwrite.js Normal file
View File

@@ -0,0 +1,361 @@
import { Client, Databases, Teams, Users, ID, Permission, Role, Query } from 'node-appwrite';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadEnv() {
const envPath = resolve(__dirname, '..', '.env');
const lines = readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
process.env[key] = value;
}
}
loadEnv();
const ENDPOINT = process.env.APPWRITE_ENDPOINT;
const PROJECT_ID = process.env.VITE_APPWRITE_PROJECT_ID;
const API_KEY = process.env.APPWRITE_API_KEY;
const DATABASE_ID = process.env.VITE_APPWRITE_DATABASE_ID || 'defekttrack_db';
if (!ENDPOINT || !PROJECT_ID || !API_KEY || API_KEY === 'YOUR_API_KEY_HERE') {
console.error('Bitte APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID und APPWRITE_API_KEY in .env setzen.');
process.exit(1);
}
const client = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setKey(API_KEY);
const databases = new Databases(client);
const teamsService = new Teams(client);
const users = new Users(client);
const TEAM_ROLES = ['admin', 'firmenleiter', 'filialleiter', 'service', 'lager'];
async function createDatabase() {
try {
const db = await databases.create(DATABASE_ID, 'DefektTrack DB');
console.log(`Datenbank erstellt: ${db.$id}`);
} catch (err) {
if (err.code === 409) {
console.log(`Datenbank existiert bereits: ${DATABASE_ID}`);
} else {
throw err;
}
}
}
async function createLocationsCollection() {
const COLLECTION_ID = 'locations';
try {
await databases.createCollection(
DATABASE_ID,
COLLECTION_ID,
'Standorte',
[
Permission.read(Role.users()),
Permission.create(Role.team('admin')),
Permission.update(Role.team('admin')),
Permission.delete(Role.team('admin')),
]
);
console.log('Collection erstellt: locations');
} catch (err) {
if (err.code === 409) {
console.log('Collection existiert bereits: locations');
return;
}
throw err;
}
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'name', 128, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'address', 256, false, '');
await databases.createBooleanAttribute(DATABASE_ID, COLLECTION_ID, 'isActive', false, true);
console.log(' Attribute fuer locations erstellt (name, address, isActive)');
}
async function createUsersMetaCollection() {
const COLLECTION_ID = 'users_meta';
try {
await databases.createCollection(
DATABASE_ID,
COLLECTION_ID,
'Benutzer-Metadaten',
[
Permission.read(Role.users()),
Permission.create(Role.team('admin')),
Permission.update(Role.users()),
Permission.delete(Role.team('admin')),
]
);
console.log('Collection erstellt: users_meta');
} catch (err) {
if (err.code === 409) {
console.log('Collection existiert bereits: users_meta');
return;
}
throw err;
}
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'userId', 64, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'locationId', 64, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'userName', 128, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'role', 32, true);
await databases.createBooleanAttribute(DATABASE_ID, COLLECTION_ID, 'mustChangePassword', false, true);
console.log(' Attribute fuer users_meta erstellt (userId, locationId, userName, role, mustChangePassword)');
}
async function createLagerstandorteCollection() {
const COLLECTION_ID = 'lagerstandorte';
try {
await databases.createCollection(
DATABASE_ID,
COLLECTION_ID,
'Lagerstandorte',
[
Permission.read(Role.users()),
Permission.create(Role.team('admin')),
Permission.update(Role.team('admin')),
Permission.delete(Role.team('admin')),
Permission.create(Role.team('filialleiter')),
Permission.update(Role.team('filialleiter')),
Permission.delete(Role.team('filialleiter')),
]
);
console.log('Collection erstellt: lagerstandorte');
} catch (err) {
if (err.code === 409) {
console.log('Collection existiert bereits: lagerstandorte');
return;
}
throw err;
}
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'name', 128, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'locationId', 64, true);
await databases.createBooleanAttribute(DATABASE_ID, COLLECTION_ID, 'isActive', false, true);
console.log(' Attribute fuer lagerstandorte erstellt (name, locationId, isActive)');
}
async function createAssetsCollection() {
const COLLECTION_ID = 'assets';
try {
await databases.createCollection(
DATABASE_ID,
COLLECTION_ID,
'Assets',
[
Permission.read(Role.users()),
Permission.create(Role.users()),
Permission.update(Role.users()),
Permission.delete(Role.team('admin')),
Permission.delete(Role.team('filialleiter')),
]
);
console.log('Collection erstellt: assets');
} catch (err) {
if (err.code === 409) {
console.log('Collection existiert bereits: assets');
return;
}
throw err;
}
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'erlNummer', 64, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'seriennummer', 128, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'artikelNr', 64, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'bezeichnung', 256, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'defekt', 1024, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'lagerstandortId', 64, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'zustaendig', 128, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'status', 32, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'prio', 16, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'kommentar', 2048, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'createdBy', 128, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'lastEditedBy', 128, false, '');
console.log(' Attribute fuer assets erstellt (erlNummer, seriennummer, artikelNr, bezeichnung, defekt, lagerstandortId, zustaendig, status, prio, kommentar, createdBy, lastEditedBy)');
}
async function createAuditLogsCollection() {
const COLLECTION_ID = 'audit_logs';
try {
await databases.createCollection(
DATABASE_ID,
COLLECTION_ID,
'Audit Logs',
[
Permission.read(Role.users()),
Permission.create(Role.users()),
]
);
console.log('Collection erstellt: audit_logs');
} catch (err) {
if (err.code === 409) {
console.log('Collection existiert bereits: audit_logs');
return;
}
throw err;
}
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'assetId', 64, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'action', 64, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'details', 2048, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'userId', 64, true);
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'userName', 128, true);
await new Promise((r) => setTimeout(r, 2000));
try {
await databases.createIndex(DATABASE_ID, COLLECTION_ID, 'idx_assetId', 'key', ['assetId'], ['ASC']);
console.log(' Index erstellt: idx_assetId');
} catch (err) {
if (err.code === 409) console.log(' Index existiert bereits: idx_assetId');
else throw err;
}
console.log(' Attribute fuer audit_logs erstellt (assetId, action, details, userId, userName)');
}
async function createTeams() {
for (const role of TEAM_ROLES) {
try {
await teamsService.create(role, role.charAt(0).toUpperCase() + role.slice(1));
console.log(`Team erstellt: ${role}`);
} catch (err) {
if (err.code === 409) {
console.log(`Team existiert bereits: ${role}`);
} else {
throw err;
}
}
}
}
async function createDefaultLocation() {
const existing = await databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(1)]);
if (existing.documents.length > 0) {
console.log(`Filiale existiert bereits: "${existing.documents[0].name}"`);
return existing.documents[0].$id;
}
await new Promise((r) => setTimeout(r, 2000));
const loc = await databases.createDocument(DATABASE_ID, 'locations', ID.unique(), {
name: 'Hauptfiliale',
address: '',
isActive: true,
});
console.log(`Filiale erstellt: "Hauptfiliale" (${loc.$id})`);
return loc.$id;
}
async function createAdminUser(defaultLocationId) {
const ADMIN_EMAIL = 'admin@defekttrack.local';
const ADMIN_PASSWORD = 'Admin1234!';
const ADMIN_NAME = 'Administrator';
let userId;
try {
const user = await users.create(ID.unique(), ADMIN_EMAIL, undefined, ADMIN_PASSWORD, ADMIN_NAME);
userId = user.$id;
console.log(`Admin-User erstellt: ${ADMIN_EMAIL} (ID: ${userId})`);
} catch (err) {
if (err.code === 409) {
console.log(`Admin-User existiert bereits: ${ADMIN_EMAIL}`);
const userList = await users.list([Query.equal('email', [ADMIN_EMAIL])]);
if (userList.users.length > 0) {
userId = userList.users[0].$id;
} else {
console.log(' Konnte bestehenden Admin nicht finden, ueberspringe Team-Zuordnung.');
return;
}
} else {
throw err;
}
}
try {
await teamsService.createMembership('admin', [], ADMIN_EMAIL, userId, undefined, `${ENDPOINT}/auth/confirm`);
console.log(' Admin dem Team "admin" hinzugefuegt');
} catch (err) {
if (err.code === 409) {
console.log(' Admin ist bereits im Team "admin"');
} else {
console.warn(' Team-Membership Warnung:', err.message);
}
}
try {
await databases.createDocument(DATABASE_ID, 'users_meta', ID.unique(), {
userId,
locationId: defaultLocationId || '',
userName: ADMIN_NAME,
role: 'admin',
mustChangePassword: false,
});
console.log(' users_meta Dokument fuer Admin erstellt');
} catch (err) {
if (err.code === 409) {
console.log(' users_meta Dokument existiert bereits');
} else {
console.warn(' users_meta Warnung:', err.message);
}
}
}
async function main() {
console.log('=== DefektTrack Appwrite Setup ===');
console.log(`Endpoint: ${ENDPOINT}`);
console.log(`Projekt: ${PROJECT_ID}`);
console.log('');
await createDatabase();
console.log('');
await createLocationsCollection();
console.log('');
await createUsersMetaCollection();
console.log('');
await createLagerstandorteCollection();
console.log('');
await createAssetsCollection();
console.log('');
await createAuditLogsCollection();
console.log('');
await createTeams();
console.log('');
const defaultLocationId = await createDefaultLocation();
console.log('');
await createAdminUser(defaultLocationId);
console.log('');
console.log('=== Setup abgeschlossen ===');
console.log('');
console.log('Admin-Login:');
console.log(' E-Mail: admin@defekttrack.local');
console.log(' Passwort: Admin1234!');
console.log('');
console.log('Vergiss nicht, den API-Key aus .env zu entfernen oder sicher aufzubewahren.');
}
main().catch((err) => {
console.error('Setup fehlgeschlagen:', err);
process.exit(1);
});

1047
src/App.css Normal file

File diff suppressed because it is too large Load Diff

81
src/App.jsx Normal file
View File

@@ -0,0 +1,81 @@
import './App.css';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Login';
import RoleRedirect from './components/RoleRedirect';
import DefektTrackApp from './components/DefektTrackApp';
import AssetDetail from './components/AssetDetail';
import AdminPanel from './components/AdminPanel';
import FilialleiterDashboard from './components/FilialleiterDashboard';
import FirmenleiterDashboard from './components/FirmenleiterDashboard';
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<RoleRedirect />
</ProtectedRoute>
}
/>
<Route
path="/tracker"
element={
<ProtectedRoute>
<DefektTrackApp />
</ProtectedRoute>
}
/>
<Route
path="/asset/:id"
element={
<ProtectedRoute>
<AssetDetail />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
}
/>
<Route
path="/filialleiter"
element={
<ProtectedRoute>
<FilialleiterDashboard />
</ProtectedRoute>
}
/>
<Route
path="/firmenleiter"
element={
<ProtectedRoute>
<FirmenleiterDashboard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;

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 }

162
src/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,162 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { ID, Query } from 'appwrite';
import { account, teams, databases, DATABASE_ID } from '../lib/appwrite';
const AuthContext = createContext(null);
const ROLE_PRIORITY = ['admin', 'firmenleiter', 'filialleiter', 'service', 'lager'];
function resolveRole(teamList) {
const teamIds = teamList.map((t) => t.$id);
for (const role of ROLE_PRIORITY) {
if (teamIds.includes(role)) return role;
}
return 'lager';
}
async function fetchUserMeta(userId) {
try {
const res = await databases.listDocuments(DATABASE_ID, 'users_meta', [
Query.equal('userId', [userId]),
]);
return res.documents[0] || null;
} catch {
return null;
}
}
async function fetchDefaultLocationId() {
try {
const res = await databases.listDocuments(DATABASE_ID, 'locations', [
Query.equal('isActive', [true]),
Query.limit(1),
]);
return res.documents[0]?.$id || '';
} catch {
return '';
}
}
async function fetchLocation(locationId) {
if (!locationId) return null;
try {
return await databases.getDocument(DATABASE_ID, 'locations', locationId);
} catch {
return null;
}
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [role, setRole] = useState(null);
const [location, setLocation] = useState(null);
const [userMeta, setUserMeta] = useState(null);
const [loading, setLoading] = useState(true);
const loadUserData = useCallback(async (currentUser) => {
const [teamList, meta] = await Promise.all([
teams.list(),
fetchUserMeta(currentUser.$id),
]);
const userRole = resolveRole(teamList.teams);
setRole(userRole);
const currentName = currentUser.name || currentUser.email || '';
let activeMeta = meta;
if (activeMeta && activeMeta.$id) {
const updates = {};
if (activeMeta.userName !== currentName) updates.userName = currentName;
if (!activeMeta.locationId) {
const defaultLoc = await fetchDefaultLocationId();
if (defaultLoc) updates.locationId = defaultLoc;
}
if (Object.keys(updates).length > 0) {
try {
const updated = await databases.updateDocument(DATABASE_ID, 'users_meta', activeMeta.$id, updates);
activeMeta = { ...activeMeta, ...updated };
} catch { /* ignore sync failure */ }
}
} else {
const defaultLoc = await fetchDefaultLocationId();
try {
activeMeta = await databases.createDocument(DATABASE_ID, 'users_meta', ID.unique(), {
userId: currentUser.$id,
locationId: defaultLoc,
userName: currentName,
role: userRole,
mustChangePassword: false,
});
} catch { /* ignore if creation fails */ }
}
setUserMeta(activeMeta);
if (activeMeta?.locationId) {
const loc = await fetchLocation(activeMeta.locationId);
setLocation(loc);
} else {
setLocation(null);
}
}, []);
useEffect(() => {
async function checkSession() {
try {
const currentUser = await account.get();
setUser(currentUser);
await loadUserData(currentUser);
} catch {
setUser(null);
setRole(null);
setLocation(null);
setUserMeta(null);
} finally {
setLoading(false);
}
}
checkSession();
}, [loadUserData]);
const login = useCallback(async (email, password) => {
await account.createEmailPasswordSession(email, password);
const currentUser = await account.get();
setUser(currentUser);
await loadUserData(currentUser);
return currentUser;
}, [loadUserData]);
const logout = useCallback(async () => {
await account.deleteSession('current');
setUser(null);
setRole(null);
setLocation(null);
setUserMeta(null);
}, []);
const value = {
user,
role,
location,
userMeta,
loading,
login,
logout,
isAdmin: role === 'admin',
isFirmenleiter: role === 'firmenleiter',
isFilialleiter: role === 'filialleiter',
isService: role === 'service',
isLager: role === 'lager',
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

87
src/hooks/useAssets.js Normal file
View File

@@ -0,0 +1,87 @@
import { useState, useCallback, useEffect } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { ID, Query } from 'appwrite';
const COLLECTION = 'assets';
const NEXT_STATUS = { offen: 'in_bearbeitung', in_bearbeitung: 'entsorgt', entsorgt: 'offen' };
export function useAssets() {
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const loadAssets = useCallback(async () => {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.orderDesc('$createdAt'),
Query.limit(500),
]);
setAssets(res.documents);
} catch (err) {
console.error('Assets laden fehlgeschlagen:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadAssets(); }, [loadAssets]);
const addAsset = useCallback(async (data) => {
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
erlNummer: data.erlNummer,
seriennummer: data.seriennummer,
artikelNr: data.artikelNr || '',
bezeichnung: data.bezeichnung || '',
defekt: data.defekt || '',
lagerstandortId: data.lagerstandortId || '',
zustaendig: data.zustaendig,
status: 'offen',
prio: data.prio,
kommentar: data.kommentar || '',
createdBy: data.createdBy || '',
lastEditedBy: data.lastEditedBy || '',
});
setAssets((prev) => [doc, ...prev]);
return doc;
}, []);
const changeStatus = useCallback(async (id) => {
const asset = assets.find((a) => a.$id === id);
if (!asset) return;
const newStatus = NEXT_STATUS[asset.status];
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, { status: newStatus });
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
}, [assets]);
const updateAsset = useCallback(async (id, data) => {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, data);
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
return updated;
}, []);
const deleteAsset = useCallback(async (id) => {
await databases.deleteDocument(DATABASE_ID, COLLECTION, id);
setAssets((prev) => prev.filter((a) => a.$id !== id));
}, []);
const getAsset = useCallback(async (id) => {
try {
return await databases.getDocument(DATABASE_ID, COLLECTION, id);
} catch (err) {
console.error('Asset laden fehlgeschlagen:', err);
return null;
}
}, []);
return { assets, loading, addAsset, changeStatus, updateAsset, deleteAsset, getAsset, reload: loadAssets };
}
export function getDaysOld(ts) {
if (!ts) return 0;
const date = typeof ts === 'string' ? new Date(ts).getTime() : ts;
return Math.floor((Date.now() - date) / 86400000);
}
export function isOverdue(a) {
const created = a.$createdAt || a.erstelltAm;
return (a.status === 'offen' || a.status === 'in_bearbeitung') && getDaysOld(created) > 7;
}

45
src/hooks/useAuditLog.js Normal file
View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { ID, Query } from 'appwrite';
const COLLECTION = 'audit_logs';
export function useAuditLog() {
const [logs, setLogs] = useState([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const loadLogs = useCallback(async (assetId) => {
setLoadingLogs(true);
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.equal('assetId', [assetId]),
Query.orderDesc('$createdAt'),
Query.limit(200),
]);
setLogs(res.documents);
} catch (err) {
console.error('Audit-Logs laden fehlgeschlagen:', err);
setLogs([]);
} finally {
setLoadingLogs(false);
}
}, []);
const addLog = useCallback(async ({ assetId, action, details, userId, userName }) => {
try {
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
assetId,
action,
details: details || '',
userId,
userName,
});
setLogs((prev) => [doc, ...prev]);
return doc;
} catch (err) {
console.error('Audit-Log schreiben fehlgeschlagen:', err);
}
}, []);
return { logs, loadingLogs, loadLogs, addLog };
}

View File

@@ -0,0 +1,33 @@
import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { Query } from 'appwrite';
export function useColleagues(locationId) {
const [colleagues, setColleagues] = useState([]);
const loadColleagues = useCallback(async () => {
try {
const queries = [Query.limit(100)];
if (locationId) {
queries.unshift(Query.equal('locationId', [locationId]));
}
const res = await databases.listDocuments(DATABASE_ID, 'users_meta', queries);
const list = res.documents
.filter((d) => d.userName)
.map((d) => ({
userId: d.userId,
userName: d.userName,
}));
setColleagues(list);
} catch (err) {
console.error('Kollegen laden fehlgeschlagen:', err);
setColleagues([]);
}
}, [locationId]);
useEffect(() => {
loadColleagues();
}, [loadColleagues]);
return { colleagues, reloadColleagues: loadColleagues };
}

View File

@@ -0,0 +1,59 @@
import { useState, useCallback, useEffect } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { ID, Query } from 'appwrite';
const COLLECTION = 'lagerstandorte';
export function useLagerstandorte(locationId) {
const [lagerstandorte, setLagerstandorte] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!locationId) {
setLagerstandorte([]);
setLoading(false);
return;
}
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.equal('locationId', [locationId]),
Query.limit(200),
]);
setLagerstandorte(res.documents);
} catch (err) {
console.error('Lagerstandorte laden fehlgeschlagen:', err);
} finally {
setLoading(false);
}
}, [locationId]);
useEffect(() => { load(); }, [load]);
const addLagerstandort = useCallback(async (name) => {
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
name,
locationId,
isActive: true,
});
setLagerstandorte((prev) => [...prev, doc]);
return doc;
}, [locationId]);
const toggleLagerstandort = useCallback(async (id) => {
const item = lagerstandorte.find((l) => l.$id === id);
if (!item) return;
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, {
isActive: !item.isActive,
});
setLagerstandorte((prev) => prev.map((l) => l.$id === id ? updated : l));
}, [lagerstandorte]);
const deleteLagerstandort = useCallback(async (id) => {
await databases.deleteDocument(DATABASE_ID, COLLECTION, id);
setLagerstandorte((prev) => prev.filter((l) => l.$id !== id));
}, []);
const activeLagerstandorte = lagerstandorte.filter((l) => l.isActive);
return { lagerstandorte, activeLagerstandorte, loading, addLagerstandort, toggleLagerstandort, deleteLagerstandort, reload: load };
}

16
src/hooks/useToast.js Normal file
View File

@@ -0,0 +1,16 @@
import { useState, useCallback, useRef } from 'react';
export function useToast() {
const [toast, setToast] = useState({ message: '', color: '#2E7D32', visible: false });
const timerRef = useRef(null);
const showToast = useCallback((message, color = '#2E7D32') => {
if (timerRef.current) clearTimeout(timerRef.current);
setToast({ message, color, visible: true });
timerRef.current = setTimeout(() => {
setToast((prev) => ({ ...prev, visible: false }));
}, 2800);
}, []);
return { toast, showToast };
}

13
src/lib/appwrite.js Normal file
View File

@@ -0,0 +1,13 @@
import { Client, Account, Databases, Teams } from "appwrite";
const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID);
const account = new Account(client);
const databases = new Databases(client);
const teams = new Teams(client);
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'defekttrack_db';
export { client, account, databases, teams };

6
src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

22
vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/v1': {
target: 'https://appwrite.webklar.com',
changeOrigin: true,
secure: true,
},
},
},
})