Compare commits

..

2 Commits

Author SHA1 Message Date
9b9b8d39a8 31 von 45 = ca. 69 %
31 punkter der todo liste abgeabeitet
2026-03-08 09:20:39 +01:00
43c9efd8f5 feat: initial commit 2026-03-08 08:34:55 +01:00
55 changed files with 14202 additions and 773 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/*"]
}
}
}

8635
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"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",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"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);
});

53
src/App.css Normal file
View File

@@ -0,0 +1,53 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
:root { --background: oklch(0.98 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.35 0.05 255); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.65 0.17 55); --accent-foreground: oklch(1 0 0); --destructive: oklch(0.58 0.22 27); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.35 0.05 255); --chart-1: oklch(0.809 0.105 251.813); --chart-2: oklch(0.623 0.214 259.815); --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); }
@theme inline { --font-sans: 'Geist Variable', sans-serif; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) * 1.4); --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); }
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/* AUDIT LOG CONSOLE */
.log-console {
background: #0f172a;
border-radius: 0.5rem;
padding: 1rem;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', 'Fira Code', monospace;
font-size: 0.75rem;
line-height: 1.8;
}
.log-entry {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 2px 0;
}
.log-time { color: #64748b; white-space: nowrap; }
.log-user { color: #60a5fa; font-weight: 700; }
.log-action { font-weight: 700; padding: 0 4px; border-radius: 3px; }
.log-details { color: #cbd5e1; }
.log-system { color: #64748b; font-style: italic; }
.log-system .log-time { display: none; }
.log-created .log-action { color: #86efac; background: rgba(134,239,172,0.15); }
.log-status .log-action { color: #fbbf24; background: rgba(251,191,36,0.15); }
.log-edit .log-action { color: #60a5fa; background: rgba(96,165,250,0.15); }

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,287 @@
import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { ID, Query } from 'appwrite';
import Header from './Header';
import { useToast } from '@/hooks/useToast';
import { useAuth } from '@/context/AuthContext';
import LagerstandortManager from './LagerstandortManager';
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
export default function AdminPanel() {
const { user, userMeta } = useAuth();
const { 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');
}
}
const statItems = [
{ label: 'Benutzer', value: stats.users },
{ label: 'Filialen', value: stats.locations },
{ label: 'Assets gesamt', value: stats.assets },
{ label: 'Lagerstandorte', value: stats.lagerstandorte },
];
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Admin Panel</h1>
<p className="mt-1 text-muted-foreground">System-Übersicht und Verwaltung</p>
</div>
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
{statItems.map((item) => (
<Card key={item.label}>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{item.value}</div>
<p className="text-sm text-muted-foreground">{item.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Filialen */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Filialen verwalten</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleAddFiliale}>
<Input
value={newFiliale.name}
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname (z.B. Kaiserslautern)"
/>
<Input
value={newFiliale.address}
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse (optional)"
/>
<Button type="submit" disabled={addingFiliale || !newFiliale.name.trim()}>
{addingFiliale ? '...' : 'Hinzufügen'}
</Button>
</form>
<Separator className="my-4" />
<div className="space-y-3">
{locations.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Keine Filialen vorhanden</p>
)}
{locations.map((loc) => (
<div
key={loc.$id}
className={`flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between ${!loc.isActive ? 'opacity-60' : ''}`}
>
{editingId === loc.$id ? (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname"
className="flex-1"
/>
<Input
value={editForm.address}
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse"
className="flex-1"
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveEdit}>Speichern</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Abbrechen</Button>
</div>
</div>
) : (
<>
<div className="flex items-center gap-3">
<span className="font-medium">{loc.name}</span>
{loc.address && <span className="text-sm text-muted-foreground">{loc.address}</span>}
<Badge variant={loc.isActive ? 'default' : 'outline'}>
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => startEdit(loc)}>Bearbeiten</Button>
<Button size="sm" variant="secondary" onClick={() => handleToggleFiliale(loc.$id)}>
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</Button>
</div>
</>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Benutzer */}
<Card>
<CardHeader>
<CardTitle>Benutzer</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{usersList.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Keine Benutzer vorhanden</p>
)}
{usersList.map((u) => {
const loc = locations.find((l) => l.$id === u.locationId);
return (
<div key={u.$id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium">{u.userName || u.userId}</span>
<Badge variant="secondary">{u.role}</Badge>
</div>
<span className="text-sm text-muted-foreground">{loc?.name || ''}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Lagerstandorte */}
<Card>
<CardHeader>
<CardTitle>Lagerstandorte</CardTitle>
</CardHeader>
<CardContent>
<Button className="mb-4 w-full" onClick={() => setShowLsManager(true)}>
Lagerstandorte verwalten
</Button>
<div className="space-y-2">
{lagerstandorte.map((l) => (
<div key={l.$id} className="flex items-center justify-between rounded-lg border p-3">
<span className="text-sm">{l.name}</span>
<Badge variant={l.isActive ? 'default' : 'outline'}>
{l.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
{showLsManager && (
<LagerstandortManager
lagerstandorte={lagerstandorte}
onAdd={addLagerstandort}
onToggle={toggleLagerstandort}
onDelete={deleteLagerstandort}
onClose={() => setShowLsManager(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,410 @@
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';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Pencil, Save, X } from 'lucide-react';
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In 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' });
}
function StatusBadge({ status }) {
if (status === 'offen') return <Badge variant="destructive">{STATUS_LABEL[status]}</Badge>;
if (status === 'in_bearbeitung') return <Badge className="border-amber-300 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">{STATUS_LABEL[status]}</Badge>;
return <Badge variant="secondary">{STATUS_LABEL[status]}</Badge>;
}
function PrioBadge({ prio }) {
if (prio === 'kritisch') return <Badge variant="destructive">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'hoch') return <Badge className="border-orange-300 bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'mittel') return <Badge className="border-yellow-300 bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">{PRIO_LABELS[prio]}</Badge>;
return <Badge className="border-green-300 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">{PRIO_LABELS[prio]}</Badge>;
}
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);
}
}
function resetForm() {
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 || '',
});
}
if (loading) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center gap-4 p-6 pt-24">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (!asset) {
return (
<div className="mx-auto max-w-4xl p-6 pt-12">
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Asset nicht gefunden</h2>
<p className="text-sm text-muted-foreground">
Das Asset mit der ID <code className="rounded bg-muted px-1 py-0.5 text-xs">{id}</code> existiert nicht.
</p>
<Button variant="outline" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Zurück zur Übersicht
</Button>
</CardContent>
</Card>
</div>
);
}
const days = getDaysOld(asset.$createdAt);
const overdue = isOverdue(asset);
return (
<div className="mx-auto max-w-4xl p-6">
{/* Back button */}
<Button variant="outline" className="mb-4" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Zurück
</Button>
{/* Header area */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">
Asset: <span className="text-blue-600 dark:text-blue-400">{asset.erlNummer || ''}</span>
</h1>
<StatusBadge status={asset.status} />
<PrioBadge prio={asset.prio} />
{overdue && (
<Badge variant="destructive" className="text-xs">
Überfällig ({days} Tage)
</Badge>
)}
</div>
{/* Properties card */}
<Card className="mb-6">
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Eigenschaften</CardTitle>
<div className="flex gap-2">
{!editing ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Bearbeiten
</Button>
) : (
<>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="mr-1.5 h-3.5 w-3.5" />
{saving ? 'Speichern…' : 'Speichern'}
</Button>
<Button variant="outline" size="sm" onClick={resetForm}>
<X className="mr-1.5 h-3.5 w-3.5" />
Abbrechen
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<PropertyField label="ERL-Nr." value={form.erlNummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
<PropertyField label="Artikelnr." value={form.artikelNr} editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
<PropertyField label="Bezeichnung" value={form.bezeichnung} editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
<PropertyField label="Seriennummer" value={form.seriennummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
<PropertyField label="Defekt" value={form.defekt} editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea className="sm:col-span-2" />
{/* Lagerstandort */}
<div className="space-y-1.5">
<Label>Lagerstandort</Label>
{editing ? (
<Select value={form.lagerstandortId} onValueChange={(v) => setForm(f => ({ ...f, lagerstandortId: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Kein Standort" />
</SelectTrigger>
<SelectContent>
{activeLagerstandorte.map((l) => (
<SelectItem key={l.$id} value={l.$id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</p>
)}
</div>
{/* Zuständig */}
<div className="space-y-1.5">
<Label>Zuständig</Label>
{editing ? (
<Select value={form.zustaendig} onValueChange={(v) => setForm(f => ({ ...f, zustaendig: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Mitarbeiter wählen" />
</SelectTrigger>
<SelectContent>
{colleagues.map((c) => (
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{asset.zustaendig || ''}</p>
)}
</div>
{/* Status */}
<div className="space-y-1.5">
<Label>Status</Label>
{editing ? (
<Select value={form.status} onValueChange={(v) => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABEL[s]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge status={asset.status} />
)}
</div>
{/* Priorität */}
<div className="space-y-1.5">
<Label>Priorität</Label>
{editing ? (
<Select value={form.prio} onValueChange={(v) => setForm(f => ({ ...f, prio: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIO_OPTIONS.map((p) => (
<SelectItem key={p} value={p}>{PRIO_LABELS[p]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<PrioBadge prio={asset.prio} />
)}
</div>
<PropertyField label="Kommentar" value={form.kommentar} editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
</div>
</CardContent>
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>Erstellt am: {formatTimestamp(asset.$createdAt)}</span>
<span>Erstellt von: <strong className="text-foreground">{asset.createdBy || ''}</strong></span>
<span>Zuletzt bearbeitet von: <strong className="text-foreground">{asset.lastEditedBy || ''}</strong></span>
<span>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span>
</CardFooter>
</Card>
{/* Audit log card */}
<Card>
<CardHeader>
<CardTitle>Änderungsprotokoll</CardTitle>
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
</div>
);
}
function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
return (
<div className={`space-y-1.5 ${className}`}>
<Label>{label}</Label>
{editing ? (
textarea ? (
<Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
) : (
<Input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
)
) : (
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || ''}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { ChevronDown } from 'lucide-react';
export default function ColumnFilter({ label, active, summary, children, onOpen, onClose }) {
return (
<th className="h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground">
<Popover open={active} onOpenChange={(nextOpen) => (nextOpen ? onOpen() : onClose())}>
<PopoverTrigger className="inline-flex items-center gap-1.5 text-sm font-medium cursor-pointer transition-colors hover:text-foreground/70">
<span>{label}</span>
{summary && (
<span className="text-xs font-normal text-amber-600 dark:text-amber-400 truncate max-w-20">
{summary}
</span>
)}
<ChevronDown
className={`h-3 w-3 shrink-0 transition-transform duration-200 ${active ? 'rotate-180' : ''}`}
/>
</PopoverTrigger>
<PopoverContent align="start" className="w-56">
{children}
</PopoverContent>
</Popover>
</th>
);
}
export function TextFilter({ value, onChange, placeholder }) {
return (
<Input
autoFocus
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Suchen...'}
/>
);
}
export function SelectFilter({ value, onChange, options }) {
return (
<div className="flex flex-col gap-0.5">
<button
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
!value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground'
}`}
onClick={() => onChange('')}
>
Alle
</button>
{options.map((opt) => (
<button
key={opt.value}
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
value === opt.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground'
}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
<DialogDescription className="sr-only">
Kommentardetails anzeigen
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{subject && (
<div className="rounded-md bg-amber-100 px-3 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
{subject}
</div>
)}
<div className="rounded-md bg-muted px-3 py-2 text-sm whitespace-pre-wrap">
{text || '(Kein weiterer Kommentar)'}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { isOverdue } from '../hooks/useAssets';
import { useAuth } from '../context/AuthContext';
import LagerstandortManager from './LagerstandortManager';
import { Card, CardContent } from '@/components/ui/card';
const STAT_CARDS = [
{ key: 'offen', color: '#DC2626', label: 'Offen' },
{ key: 'bearbeitung', color: '#F59E0B', label: 'In Bearbeitung' },
{ key: 'entsorgt', color: '#6B7280', label: 'Entsorgt' },
{ key: 'overdue', color: '#2563EB', label: 'Überfällig (>7 Tage)' },
];
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="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
{STAT_CARDS.map(({ key, color, label }) => (
<Card key={key} className="py-0" style={{ borderTop: `3px solid ${color}` }}>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className="text-sm text-muted-foreground mt-1">{label}</p>
</CardContent>
</Card>
))}
{(isAdmin || isFilialleiter) && (
<Card
className="py-0 cursor-pointer transition-colors hover:bg-muted/50"
style={{ borderTop: '3px solid #F57C00' }}
onClick={() => setShowManager(true)}
>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{lagerstandorte.length}</div>
<p className="text-sm text-muted-foreground mt-1">Lagerstandorte verwalten</p>
</CardContent>
</Card>
)}
</div>
{showManager && (
<LagerstandortManager
lagerstandorte={lagerstandorte}
onAdd={onAddLagerstandort}
onToggle={onToggleLagerstandort}
onDelete={onDeleteLagerstandort}
onClose={() => setShowManager(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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 }));
}
function setField(name, value) {
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 (
<Card className="border-0 shadow-none">
<CardHeader className="px-0 pt-0">
<CardTitle>Defekte Ware erfassen</CardTitle>
</CardHeader>
<CardContent className="px-0">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>
<Input
id="erlNummer"
name="erlNummer"
value={form.erlNummer}
onChange={handleChange}
placeholder="z.B. ERL-00001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="seriennummer">Seriennummer *</Label>
<Input
id="seriennummer"
name="seriennummer"
value={form.seriennummer}
onChange={handleChange}
placeholder="z.B. SN-ABC123456"
/>
</div>
<div className="space-y-2">
<Label htmlFor="artikelNr">Artikelnummer</Label>
<Input
id="artikelNr"
name="artikelNr"
value={form.artikelNr}
onChange={handleChange}
placeholder="z.B. ART-20341"
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezeichnung">Bezeichnung</Label>
<Input
id="bezeichnung"
name="bezeichnung"
value={form.bezeichnung}
onChange={handleChange}
placeholder="z.B. Hydraulikpumpe XL"
/>
</div>
<div className="space-y-2">
<Label htmlFor="defekt">Defektbeschreibung</Label>
<Textarea
id="defekt"
name="defekt"
value={form.defekt}
onChange={handleChange}
placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"
/>
</div>
<div className="space-y-2">
<Label>Lagerstandort</Label>
<Select value={form.lagerstandortId} onValueChange={(v) => setField('lagerstandortId', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Standort wählen" />
</SelectTrigger>
<SelectContent>
{(lagerstandorte || []).map((ls) => (
<SelectItem key={ls.$id} value={ls.$id}>
{ls.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Zuständig *</Label>
<Select value={form.zustaendig} onValueChange={(v) => setField('zustaendig', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Mitarbeiter wählen" />
</SelectTrigger>
<SelectContent>
{(colleagues || []).map((c) => (
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priorität *</Label>
<Select value={form.prio} onValueChange={(v) => setField('prio', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priorität wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="mittel">Mittel</SelectItem>
<SelectItem value="hoch">Hoch</SelectItem>
<SelectItem value="kritisch">Kritisch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="kommentar">Kommentar</Label>
<Textarea
id="kommentar"
name="kommentar"
value={form.kommentar}
onChange={handleChange}
placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)"
/>
</div>
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700">
Ware erfassen
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,305 @@
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';
import { Card } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Printer, Package } from 'lucide-react';
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 PRIO_COLORS = {
kritisch: 'bg-red-600',
hoch: 'bg-orange-500',
mittel: 'bg-yellow-500',
niedrig: 'bg-green-500',
};
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' },
];
const STATUS_BADGE_CONFIG = {
offen: { variant: 'destructive' },
in_bearbeitung: { variant: 'default', className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
entsorgt: { variant: 'secondary' },
};
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 (
<Card className="py-0 gap-0">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="text-sm font-medium text-muted-foreground">
{filtered.length} Assets
</span>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-3.5 w-3.5" />
Drucken
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<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="Sortierung" 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>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => {
const days = getDaysOld(a.$createdAt);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
return (
<TableRow
key={a.$id}
className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
>
<TableCell>
<div className="flex items-center gap-2">
<span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
<span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || ''}</span>
</div>
</TableCell>
<TableCell>
<div className="font-medium">{a.artikelNr}</div>
<div className="text-xs text-muted-foreground">{a.bezeichnung}</div>
</TableCell>
<TableCell className="font-mono text-xs">{a.seriennummer || ''}</TableCell>
<TableCell className="max-w-[180px] text-xs truncate">{a.defekt}</TableCell>
<TableCell className="text-xs">{resolveStandortName(a, lagerstandorte || [])}</TableCell>
<TableCell>
<Badge variant={badgeCfg.variant} className={badgeCfg.className}>
{STATUS_LABEL[a.status]}
</Badge>
</TableCell>
<TableCell className="text-xs">
{ageText}
{overdue && (
<div className="text-amber-600 dark:text-amber-400 font-medium mt-0.5">Überfällig!</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Button variant="secondary" size="sm" onClick={() => handleStatusChange(a.$id)}>
{NEXT_LABEL[a.status]}
</Button>
{a.kommentar && (
<Button variant="outline" size="sm" onClick={() => setCommentAsset(a)}>
Info
</Button>
)}
<Button variant="default" size="sm" onClick={() => navigate(`/asset/${a.$id}`)}>
Bearbeiten
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mb-3 opacity-30" />
<p className="font-medium">Keine Assets gefunden.</p>
<p className="text-sm mt-2">Passe die Filter an oder erfasse ein neues Asset.</p>
</div>
)}
{commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)}
</Card>
);
}

View File

@@ -0,0 +1,106 @@
import { useCallback } from 'react';
import Header from './Header';
import Dashboard from './Dashboard';
import DefektForm from './DefektForm';
import DefektTable from './DefektTable';
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 { 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 (
<div className="flex h-screen flex-col overflow-hidden">
<Header assets={assets} showToast={showToast} />
<div className="flex flex-1 overflow-hidden">
{/* Sidebar fixed left */}
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-r bg-background p-4 md:block">
<DefektForm
onAdd={handleAdd}
showToast={showToast}
lagerstandorte={activeLagerstandorte}
colleagues={colleagues}
/>
</aside>
{/* Main content scrollable */}
<main className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="p-4">
<Dashboard
assets={assets}
lagerstandorte={lagerstandorte}
onAddLagerstandort={addLagerstandort}
onToggleLagerstandort={toggleLagerstandort}
onDeleteLagerstandort={deleteLagerstandort}
/>
</div>
<div className="px-4 pb-6">
<DefektTable
assets={assets}
onChangeStatus={handleStatusChange}
showToast={showToast}
lagerstandorte={lagerstandorte}
/>
</div>
{/* Mobile: form below table */}
<div className="p-4 md:hidden">
<DefektForm
onAdd={handleAdd}
showToast={showToast}
lagerstandorte={activeLagerstandorte}
colleagues={colleagues}
/>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
import { useToast } from '@/hooks/useToast';
import { useAuth } from '@/context/AuthContext';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
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 { 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: 'text-green-600' };
if (current < previous) return { arrow: '▼', cls: 'text-red-600' };
return { arrow: '', cls: 'text-muted-foreground' };
}
const dayTrend = trendArrow(todayCount, yesterdayCount);
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
const comparisonMax = Math.max(ownTotal, avgAllFilialen, 1);
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Filialleiter Dashboard</h1>
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
</div>
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{todayCount}</div>
<p className="text-sm text-muted-foreground">Heute erfasst</p>
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
{dayTrend.arrow} Gestern: {yesterdayCount}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{thisMonthCount}</div>
<p className="text-sm text-muted-foreground">Diesen Monat</p>
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{ownTotal}</div>
<p className="text-sm text-muted-foreground">Meine Filiale</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{avgAllFilialen}</div>
<p className="text-sm text-muted-foreground"> Alle Filialen</p>
</CardContent>
</Card>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Filialvergleich</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="w-32 shrink-0 text-sm font-medium">Meine Filiale</span>
<Progress value={Math.round((ownTotal / comparisonMax) * 100)} className="flex-1" />
<span className="w-12 text-right text-sm font-semibold tabular-nums">{ownTotal}</span>
</div>
<div className="flex items-center gap-4">
<span className="w-32 shrink-0 text-sm font-medium"> Durchschnitt</span>
<Progress value={Math.round((avgAllFilialen / comparisonMax) * 100)} className="flex-1" />
<span className="w-12 text-right text-sm font-semibold tabular-nums">{avgAllFilialen}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Mitarbeiter-Performance</CardTitle>
</CardHeader>
<CardContent>
{employeeStats.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">Keine Mitarbeiter gefunden</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Mitarbeiter</TableHead>
<TableHead className="text-right">Zugewiesen</TableHead>
<TableHead className="text-right">Offen</TableHead>
<TableHead className="text-right">In Bearbeitung</TableHead>
<TableHead className="text-right">Erledigt</TableHead>
<TableHead className="w-48">Erledigungsrate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employeeStats.map((e) => (
<TableRow key={e.name}>
<TableCell className="font-medium">{e.name}</TableCell>
<TableCell className="text-right tabular-nums">{e.total}</TableCell>
<TableCell className="text-right tabular-nums">{e.open}</TableCell>
<TableCell className="text-right tabular-nums">{e.inProgress}</TableCell>
<TableCell className="text-right tabular-nums">{e.resolved}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={e.rate} className="flex-1" />
<span className="w-10 text-right text-xs font-medium tabular-nums">{e.rate}%</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
import { useToast } from '@/hooks/useToast';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Building2, Users, Package, CheckCircle, AlertCircle, Clock, CircleCheck } from 'lucide-react';
export default function FirmenleiterDashboard() {
const { 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="mx-auto max-w-7xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">Firmenleiter Dashboard</h1>
<p className="text-sm text-muted-foreground">Übersicht aller Filialen</p>
</div>
{/* Main stats */}
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Building2 className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{locations.length}</p>
<p className="text-xs text-muted-foreground">Filialen</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{allUsers.length}</p>
<p className="text-xs text-muted-foreground">Mitarbeiter gesamt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
<Package className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{totalAssets}</p>
<p className="text-xs text-muted-foreground">Assets gesamt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}%
</p>
<p className="text-xs text-muted-foreground">Erledigungsrate</p>
</div>
</CardContent>
</Card>
</div>
{/* Status row */}
<div className="mb-6 grid grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<AlertCircle className="h-5 w-5 text-red-600" />
<div>
<p className="text-xl font-bold text-red-600">{totalOpen}</p>
<p className="text-xs text-muted-foreground">Offen</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<Clock className="h-5 w-5 text-amber-600" />
<div>
<p className="text-xl font-bold text-amber-600">{totalInProgress}</p>
<p className="text-xs text-muted-foreground">In Bearbeitung</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<CircleCheck className="h-5 w-5 text-green-600" />
<div>
<p className="text-xl font-bold text-green-600">{totalResolved}</p>
<p className="text-xs text-muted-foreground">Erledigt</p>
</div>
</CardContent>
</Card>
</div>
{/* Filialen section */}
<Card>
<CardHeader>
<CardTitle>Alle Filialen</CardTitle>
</CardHeader>
<CardContent>
{filialeStats.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Filialen vorhanden</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filialeStats.map((f) => (
<Card key={f.id} className={f.isActive ? '' : 'opacity-60'}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{f.name}</CardTitle>
<Badge variant={f.isActive ? 'default' : 'secondary'}>
{f.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
{f.address && (
<p className="text-xs text-muted-foreground">{f.address}</p>
)}
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-center">
<span className="text-lg font-semibold">{f.userCount}</span>
<span className="text-xs text-muted-foreground">Mitarbeiter</span>
</div>
<div className="flex flex-col items-center">
<span className="text-lg font-semibold">{f.lsCount}</span>
<span className="text-xs text-muted-foreground">Lagerstandorte</span>
</div>
<div className="flex flex-col items-center">
<span className="text-lg font-semibold">{f.assetsTotal}</span>
<span className="text-xs text-muted-foreground">Assets</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}

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

@@ -0,0 +1,151 @@
import { useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Download, LogOut, User, ChevronDown } from 'lucide-react';
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 className="sticky top-0 z-50 flex items-center justify-between border-b bg-background/95 px-5 py-2.5 backdrop-blur-sm">
<div className="flex items-center gap-3">
<span className="text-lg font-bold tracking-tight">
Defekt<span className="text-amber-500">Track</span>
</span>
{locationName && (
<>
<Separator orientation="vertical" className="!h-5" />
<span className="text-sm text-muted-foreground">{locationName}</span>
</>
)}
</div>
<div className="flex items-center gap-1">
<nav className="flex items-center gap-0.5">
{!isOnTracker && (
<Button variant="ghost" size="sm" onClick={() => navigate('/tracker')}>
DefektTrack
</Button>
)}
{isAdmin && !isOnAdmin && (
<Button variant="ghost" size="sm" onClick={() => navigate('/admin')}>
Admin
</Button>
)}
{(isFilialleiter || isAdmin) && !isOnFilialleiter && (
<Button variant="ghost" size="sm" onClick={() => navigate('/filialleiter')}>
Filialleiter
</Button>
)}
{(isFirmenleiter || isAdmin) && !isOnFirmenleiter && (
<Button variant="ghost" size="sm" onClick={() => navigate('/firmenleiter')}>
Firmenleiter
</Button>
)}
{isOnTracker && assets && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="mr-1.5 h-3.5 w-3.5" />
Export
</Button>
)}
</nav>
{user && (
<>
<Separator orientation="vertical" className="!h-5 mx-1.5" />
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline">{user.name || user.email}</span>
<Badge variant="outline" className="ml-0.5 text-[10px] font-medium">
{ROLE_LABELS[role] || role}
</Badge>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{user.name || user.email}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Lagerstandorte verwalten</DialogTitle>
<DialogDescription className="sr-only">
Lagerstandorte hinzufügen, aktivieren oder löschen
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAdd} className="flex items-center gap-2">
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neuer Standort (z.B. Regal B-12)"
className="flex-1"
/>
<Button type="submit" disabled={adding || !newName.trim()} className="bg-amber-600 hover:bg-amber-700">
{adding ? '...' : 'Hinzufügen'}
</Button>
</form>
<Separator />
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{lagerstandorte.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
Noch keine Lagerstandorte angelegt.
</p>
)}
{lagerstandorte.map((ls) => (
<div
key={ls.$id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${ls.isActive ? '' : 'text-muted-foreground line-through'}`}>
{ls.name}
</span>
<Badge variant={ls.isActive ? 'default' : 'secondary'}>
{ls.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onToggle(ls.$id)}>
{ls.isActive ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button variant="destructive" size="sm" onClick={() => onDelete(ls.$id)}>
Löschen
</Button>
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

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

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { AlertCircle, Loader2 } from 'lucide-react';
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="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="text-center space-y-1">
<CardTitle className="text-3xl font-bold tracking-tight">
Defekt<span className="text-amber-500">Track</span>
</CardTitle>
<CardDescription>
Lager &amp; Logistik · Defekte Ware im Griff
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
autoComplete="email"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
autoComplete="current-password"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? 'Anmelden...' : 'Anmelden'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md shadow-lg">
<CardContent className="flex flex-col items-center gap-4 py-10">
<p className="text-2xl font-bold tracking-tight">
Defekt<span className="text-amber-500">Track</span>
</p>
<div className="w-full space-y-3">
<Skeleton className="h-4 w-3/4 mx-auto" />
<Skeleton className="h-4 w-1/2 mx-auto" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
</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 />;
}

View File

@@ -0,0 +1,49 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}) {
return useRender({
defaultTagName: "span",
props: mergeProps({
className: cn(badgeVariants({ variant }), className),
}, props),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants }

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 }

114
src/components/ui/card.jsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} />
);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />
}>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({
...props
}) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props} />
);
}
function DropdownMenuSub({
...props
}) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props} />
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}) {
return (<MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function DropdownMenuShortcut({
className,
...props
}) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props} />
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Input }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props} />
);
}
export { Label }

View File

@@ -0,0 +1,89 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50">
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({
className,
...props
}) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props} />
);
}
function PopoverTitle({
className,
...props
}) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props} />
);
}
function PopoverDescription({
className,
...props
}) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props} />
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,84 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
function ProgressTrack({
className,
...props
}) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props} />
);
}
function ProgressIndicator({
className,
...props
}) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props} />
);
}
function ProgressLabel({
className,
...props
}) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props} />
);
}
function ProgressValue({
className,
...props
}) {
return (
<ProgressPrimitive.Value
className={cn("ml-auto text-sm text-muted-foreground tabular-nums", className)}
data-slot="progress-value"
{...props} />
);
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({
className,
...props
}) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props} />
);
}
function SelectValue({
className,
...props
}) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50">
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props} />
);
}
function SelectItem({
className,
children,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span
className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,22 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props} />
);
}
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} />
);
}
export { Skeleton }

View File

@@ -0,0 +1,48 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner";
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)"
}
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props} />
);
}
export { Toaster }

123
src/components/ui/table.jsx Normal file
View File

@@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({
className,
...props
}) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
);
}
function TableHeader({
className,
...props
}) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props} />
);
}
function TableBody({
className,
...props
}) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
);
}
function TableFooter({
className,
...props
}) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
);
}
function TableRow({
className,
...props
}) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
);
}
function TableHead({
className,
...props
}) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCell({
className,
...props
}) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCaption({
className,
...props
}) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({
className,
...props
}) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />);
}
function Tooltip({
...props
}) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50">
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
<TooltipPrimitive.Arrow
className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

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 { useCallback } from 'react';
import { toast } from 'sonner';
export function useToast() {
const showToast = useCallback((message, color) => {
if (color === '#C62828' || color === 'error') {
toast.error(message);
} else if (color === '#607D8B' || color === 'info') {
toast.info(message);
} else {
toast.success(message);
}
}, []);
return { 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));
}

12
src/main.jsx Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from '@/components/ui/sonner'
import App from './App.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<Toaster richColors position="bottom-right" />
</StrictMode>,
)

185
todos.md
View File

@@ -6,159 +6,176 @@ Ziel: Aufbau eines sicheren, rollenbasierten Defekt- und Retouren-Management-Sys
# PRIORITY 1 CORE SECURITY & ACCESS
## 1. Login-Gate vor dem Laden der App
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
- [x] ## 1. Login-Gate vor dem Laden der App
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
## 2. Session-System (Login bleibt bis Browser geschlossen wird)
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
- [x] ## 2. Session-System (Login bleibt bis Browser geschlossen wird)
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
- [x] ## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
## 4. Rollen-System
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
- [x] ## 4. Rollen-System
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
- [ ] ## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
- [ ] ## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
## 7. PIN-Login-System
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
- [ ] ## 7. PIN-Login-System
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
## 8. Passwort-Hashing
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden.
- [x] ## 8. Passwort-Hashing
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden (Appwrite übernimmt dies).
## 9. Zugriffskontrolle (Role Based Access Control)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
- [x] ## 9. Zugriffskontrolle (Role Based Access Control)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
- [x] ## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
---
# PRIORITY 2 USER EXPERIENCE & DASHBOARDS
## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
- [x] ## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
## 12. Lagerkraft-Startseite
Zeigt primär offene Artikel und operative Aufgaben.
- [x] ## 12. Lagerkraft-Startseite
Zeigt primär offene Artikel und operative Aufgaben (Tracker).
## 13. Service-Startseite
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare.
- [x] ## 13. Service-Startseite
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare (Tracker).
## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
- [x] ## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
- [x] ## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
- [ ] ## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
---
# PRIORITY 3 DEFECT MANAGEMENT CORE
## 17. Defektfall-System
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen.
- [x] ## 17. Defektfall-System
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen (Assets-Collection).
## 18. Status-Workflow
Statussystem für Fälle (Offen → In Bearbeitung → Erledigt → Entsorgt).
- [x] ## 18. Status-Workflow
Statussystem für Fälle (Offen → In Bearbeitung → Entsorgt).
## 19. Prioritätssystem
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
- [x] ## 19. Prioritätssystem
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
## 20. Verantwortlichkeits-System
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden.
- [x] ## 20. Verantwortlichkeits-System
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden (Zuständig-Dropdown aus Appwrite-Benutzern der Filiale).
## 21. Kommentar-System
Interne Kommentare und technische Notizen zu jedem Defektfall.
- [x] ## 21. Kommentar-System
Interne Kommentare und technische Notizen zu jedem Defektfall (Kommentar-Feld, CommentPopup für Anzeige).
## 22. Defekt-Historie
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden.
- [x] ## 22. Defekt-Historie
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden (Audit-Log pro Asset).
---
# PRIORITY 4 SEARCH & FILTERING
## 23. Erweiterte Suche
Suche nach ERL-Nummer, Seriennummer, Artikelnummer oder Beschreibung.
- [x] ## 23. Erweiterte Suche
Suche nach ERL-Nummer, Artikelnummer, Seriennummer, Defektbeschreibung.
## 24. Statusfilter
Filter für offene, in Bearbeitung befindliche, erledigte oder entsorgte Artikel.
- [x] ## 24. Statusfilter
Filter für offene, in Bearbeitung befindliche, entsorgte Artikel.
## 25. Prioritätsfilter
Filter für kritische oder wichtige Fälle.
- [x] ## 25. Prioritätsfilter
Filter/Sortierung nach Priorität (kritisch, hoch, mittel, niedrig).
## 26. Mitarbeiterfilter
Anzeige der Fälle nach zuständigem Mitarbeiter.
- [x] ## 26. Mitarbeiterfilter
Anzeige der Fälle nach zuständigem Mitarbeiter (Sortierung „Mir zugewiesen“).
---
# PRIORITY 5 STATISTICS & ANALYTICS
## 27. Mitarbeiterstatistiken
Eigene offenen, erledigten und überfälligen Fälle eines Mitarbeiters.
- [x] ## 27. Mitarbeiterstatistiken
Eigene offenen, erledigten und überfälligen Fälle (Filialleiter-Dashboard: Mitarbeiter-Performance mit Erledigungsrate).
## 28. Filialstatistiken
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
- [x] ## 28. Filialstatistiken
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
## 29. Unternehmensstatistiken
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen.
- [x] ## 29. Unternehmensstatistiken
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen (Firmenleiter-Dashboard).
## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
- [ ] ## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
- [ ] ## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
---
# PRIORITY 6 ORGANISATION STRUCTURE
## 32. Filial-System
Unterstützung mehrerer Standorte innerhalb eines Unternehmens.
- [x] ## 32. Filial-System
Unterstützung mehrerer Standorte innerhalb eines Unternehmens (locations-Collection, Admin verwaltet Filialen).
## 33. Standortzuweisung für Benutzer
Benutzer gehören zu einer bestimmten Filiale.
- [x] ## 33. Standortzuweisung für Benutzer
Benutzer gehören zu einer bestimmten Filiale (users_meta.locationId).
## 34. Standortfilter für Daten
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
- [x] ## 34. Standortfilter für Daten
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
---
# PRIORITY 7 SYSTEM FEATURES
## 35. Export-Funktion
Datenexport für Berichte oder Archivierung.
- [x] ## 35. Export-Funktion
Datenexport für Berichte oder Archivierung (JSON-Export im Header).
## 36. Import-Funktion
Import von Datensätzen für Migration oder Synchronisation.
- [ ] ## 36. Import-Funktion
Import von Datensätzen für Migration oder Synchronisation.
## 37. Druckansicht
Optimierte Druckansicht für Berichte oder Listen.
- [x] ## 37. Druckansicht
Optimierte Druckansicht für Berichte oder Listen (Drucken-Button in der Asset-Tabelle).
## 38. Benachrichtigungen
Systemmeldungen bei kritischen oder überfälligen Defektfällen.
- [ ] ## 38. Benachrichtigungen
Systemmeldungen bei kritischen oder überfälligen Defektfällen (Toasts vorhanden, keine gezielten Alerts).
---
# PRIORITY 8 FUTURE FEATURES
## 39. Datei-Uploads
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
- [ ] ## 39. Datei-Uploads
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
## 40. Mobile Optimierung
Optimierte Nutzung für Tablets oder mobile Geräte im Lager.
- [x] ## 40. Mobile Optimierung
Optimierte Nutzung für Tablets oder mobile Geräte im Lager (responsive Layout, Sidebar ausgeblendet auf Mobile, Form unten).
## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen.
- [ ] ## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen.
## 42. Automatische Eskalationen
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
- [ ] ## 42. Automatische Eskalationen
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
---
# ERGÄNZUNGEN (bereits umgesetzt, nicht im Original-Roadmap)
- [x] **Appwrite-Integration** Auth, Teams, Databases; Collections: locations, users_meta, lagerstandorte, assets, audit_logs.
- [x] **Produkte → Assets** Umbenennung, eigene Collection, Verknüpfung mit Lagerstandort und Location.
- [x] **Lagerstandorte** Pro Filiale mehrere Lagerstandorte, verwaltbar über Button im Dashboard (LagerstandortManager).
- [x] **Asset-Detailseite** Eigene Seite `/asset/:id` mit allen Eigenschaften, Bearbeiten-Modus, Audit-Log (Konsolen-Style).
- [x] **Bearbeiten statt Löschen** Button „Bearbeiten“ öffnet Asset-Detailseite.
- [x] **Header** Standortname neben DefektTrack, Navigation Admin / Filialleiter / Firmenleiter, User-Dropdown mit Logout, Export.
- [x] **Admin: Filialen verwalten** Filialen anlegen, bearbeiten, aktivieren/deaktivieren, löschen.
- [x] **Zuständig-Dropdown** Default eigener Name, Auswahl nur Mitarbeiter der gleichen Filiale (Appwrite).
- [x] **Audit-Log Erfassung** Bei Erstellung: „für sich selbst erfasst“ vs. „von X für Y erfasst“; bei Bearbeitung und Statusänderung.
- [x] **UI-Redesign mit shadcn/ui** Button, Input, Card, Table, Badge, Dialog, Select, Sonner-Toast, einheitliches Design.
- [x] **Header clean/minimal** Heller Header, keine horizontale Scrollbar, User-Dropdown mit DropdownMenuGroup-Fix.
- [x] **Layout: fixe Sidebar links** „Defekte Ware erfassen“ als fixe linke Sidebar (380px), rechts Status-Karten + Asset-Tabelle, kein horizontaler Scroll.
---

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