Compare commits
2 Commits
3eb7c3ca8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9b8d39a8 | |||
| 43c9efd8f5 |
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
25
components.json
Normal 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
38
eslint.config.js
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
705
index.html
705
index.html
@@ -1,690 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<title>DefektTrack – Defekte Ware verwalten</title>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<style>
|
<link
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap"
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; background: #F4F6FA; color: #333; }
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
/* HEADER */
|
<link rel="icon" type="image/svg+xml" href="/appwrite.svg" />
|
||||||
header {
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
background: #1A2B4A;
|
<title>DefektTrack – Defekte Ware verwalten</title>
|
||||||
color: white;
|
</head>
|
||||||
padding: 0 24px;
|
<body>
|
||||||
display: flex;
|
<div id="root"></div>
|
||||||
align-items: center;
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
justify-content: space-between;
|
</body>
|
||||||
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 (>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>
|
</html>
|
||||||
|
|||||||
690
index.html.bak
Normal file
690
index.html.bak
Normal 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 (>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
8
jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8635
package-lock.json
generated
Normal file
8635
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal 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
8
public/appwrite.svg
Normal 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
361
scripts/setup-appwrite.js
Normal 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
53
src/App.css
Normal 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
81
src/App.jsx
Normal 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;
|
||||||
287
src/components/AdminPanel.jsx
Normal file
287
src/components/AdminPanel.jsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
src/components/AssetDetail.jsx
Normal file
410
src/components/AssetDetail.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/ColumnFilter.jsx
Normal file
68
src/components/ColumnFilter.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/CommentPopup.jsx
Normal file
50
src/components/CommentPopup.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/Dashboard.jsx
Normal file
62
src/components/Dashboard.jsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/DefektForm.jsx
Normal file
202
src/components/DefektForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/components/DefektTable.jsx
Normal file
305
src/components/DefektTable.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/DefektTrackApp.jsx
Normal file
106
src/components/DefektTrackApp.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/components/FilialleiterDashboard.jsx
Normal file
225
src/components/FilialleiterDashboard.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/components/FirmenleiterDashboard.jsx
Normal file
197
src/components/FirmenleiterDashboard.jsx
Normal 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
151
src/components/Header.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/LagerstandortManager.jsx
Normal file
88
src/components/LagerstandortManager.jsx
Normal 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
93
src/components/Login.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ProtectedRoute.jsx
Normal file
33
src/components/ProtectedRoute.jsx
Normal 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;
|
||||||
|
}
|
||||||
13
src/components/RoleRedirect.jsx
Normal file
13
src/components/RoleRedirect.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function RoleRedirect() {
|
||||||
|
const { role, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (role === 'admin') return <Navigate to="/admin" replace />;
|
||||||
|
if (role === 'firmenleiter') return <Navigate to="/firmenleiter" replace />;
|
||||||
|
if (role === 'filialleiter') return <Navigate to="/filialleiter" replace />;
|
||||||
|
return <Navigate to="/tracker" replace />;
|
||||||
|
}
|
||||||
49
src/components/ui/badge.jsx
Normal file
49
src/components/ui/badge.jsx
Normal 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 }
|
||||||
57
src/components/ui/button.jsx
Normal file
57
src/components/ui/button.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
114
src/components/ui/card.jsx
Normal file
114
src/components/ui/card.jsx
Normal 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,
|
||||||
|
}
|
||||||
155
src/components/ui/dialog.jsx
Normal file
155
src/components/ui/dialog.jsx
Normal 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,
|
||||||
|
}
|
||||||
252
src/components/ui/dropdown-menu.jsx
Normal file
252
src/components/ui/dropdown-menu.jsx
Normal 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,
|
||||||
|
}
|
||||||
23
src/components/ui/input.jsx
Normal file
23
src/components/ui/input.jsx
Normal 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 }
|
||||||
20
src/components/ui/label.jsx
Normal file
20
src/components/ui/label.jsx
Normal 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 }
|
||||||
89
src/components/ui/popover.jsx
Normal file
89
src/components/ui/popover.jsx
Normal 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,
|
||||||
|
}
|
||||||
84
src/components/ui/progress.jsx
Normal file
84
src/components/ui/progress.jsx
Normal 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,
|
||||||
|
}
|
||||||
46
src/components/ui/scroll-area.jsx
Normal file
46
src/components/ui/scroll-area.jsx
Normal 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 }
|
||||||
191
src/components/ui/select.jsx
Normal file
191
src/components/ui/select.jsx
Normal 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,
|
||||||
|
}
|
||||||
22
src/components/ui/separator.jsx
Normal file
22
src/components/ui/separator.jsx
Normal 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 }
|
||||||
15
src/components/ui/skeleton.jsx
Normal file
15
src/components/ui/skeleton.jsx
Normal 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 }
|
||||||
48
src/components/ui/sonner.jsx
Normal file
48
src/components/ui/sonner.jsx
Normal 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
123
src/components/ui/table.jsx
Normal 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,
|
||||||
|
}
|
||||||
20
src/components/ui/textarea.jsx
Normal file
20
src/components/ui/textarea.jsx
Normal 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 }
|
||||||
59
src/components/ui/tooltip.jsx
Normal file
59
src/components/ui/tooltip.jsx
Normal 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
162
src/context/AuthContext.jsx
Normal 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
87
src/hooks/useAssets.js
Normal 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
45
src/hooks/useAuditLog.js
Normal 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 };
|
||||||
|
}
|
||||||
33
src/hooks/useColleagues.js
Normal file
33
src/hooks/useColleagues.js
Normal 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 };
|
||||||
|
}
|
||||||
59
src/hooks/useLagerstandorte.js
Normal file
59
src/hooks/useLagerstandorte.js
Normal 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
16
src/hooks/useToast.js
Normal 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
13
src/lib/appwrite.js
Normal 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
6
src/lib/utils.js
Normal 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
12
src/main.jsx
Normal 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
185
todos.md
@@ -6,159 +6,176 @@ Ziel: Aufbau eines sicheren, rollenbasierten Defekt- und Retouren-Management-Sys
|
|||||||
|
|
||||||
# PRIORITY 1 – CORE SECURITY & ACCESS
|
# PRIORITY 1 – CORE SECURITY & ACCESS
|
||||||
|
|
||||||
## 1. Login-Gate vor dem Laden der App
|
- [x] ## 1. Login-Gate vor dem Laden der App
|
||||||
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
|
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
|
||||||
|
|
||||||
## 2. Session-System (Login bleibt bis Browser geschlossen wird)
|
- [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.
|
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
|
||||||
|
|
||||||
## 3. Benutzerverwaltung (Admin)
|
- [x] ## 3. Benutzerverwaltung (Admin)
|
||||||
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
|
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
|
||||||
|
|
||||||
## 4. Rollen-System
|
- [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.
|
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
|
||||||
|
|
||||||
## 5. Startpasswort-System
|
- [ ] ## 5. Startpasswort-System
|
||||||
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
|
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
|
||||||
|
|
||||||
## 6. Passwort-Änderungspflicht
|
- [ ] ## 6. Passwort-Änderungspflicht
|
||||||
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
|
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
|
||||||
|
|
||||||
## 7. PIN-Login-System
|
- [ ] ## 7. PIN-Login-System
|
||||||
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
|
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
|
||||||
|
|
||||||
## 8. Passwort-Hashing
|
- [x] ## 8. Passwort-Hashing
|
||||||
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden.
|
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)
|
- [x] ## 9. Zugriffskontrolle (Role Based Access Control)
|
||||||
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
|
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
|
||||||
|
|
||||||
## 10. Audit-Log
|
- [x] ## 10. Audit-Log
|
||||||
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
|
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 2 – USER EXPERIENCE & DASHBOARDS
|
# PRIORITY 2 – USER EXPERIENCE & DASHBOARDS
|
||||||
|
|
||||||
## 11. Rollenbasierte Startseiten
|
- [x] ## 11. Rollenbasierte Startseiten
|
||||||
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
|
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
|
||||||
|
|
||||||
## 12. Lagerkraft-Startseite
|
- [x] ## 12. Lagerkraft-Startseite
|
||||||
Zeigt primär offene Artikel und operative Aufgaben.
|
Zeigt primär offene Artikel und operative Aufgaben (Tracker).
|
||||||
|
|
||||||
## 13. Service-Startseite
|
- [x] ## 13. Service-Startseite
|
||||||
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare.
|
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare (Tracker).
|
||||||
|
|
||||||
## 14. Filialleiter-Dashboard
|
- [x] ## 14. Filialleiter-Dashboard
|
||||||
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
|
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
|
||||||
|
|
||||||
## 15. Firmenleiter-Dashboard
|
- [x] ## 15. Firmenleiter-Dashboard
|
||||||
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
|
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
|
||||||
|
|
||||||
## 16. Automatische Filter je Rolle
|
- [ ] ## 16. Automatische Filter je Rolle
|
||||||
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
|
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 3 – DEFECT MANAGEMENT CORE
|
# PRIORITY 3 – DEFECT MANAGEMENT CORE
|
||||||
|
|
||||||
## 17. Defektfall-System
|
- [x] ## 17. Defektfall-System
|
||||||
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen.
|
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen (Assets-Collection).
|
||||||
|
|
||||||
## 18. Status-Workflow
|
- [x] ## 18. Status-Workflow
|
||||||
Statussystem für Fälle (Offen → In Bearbeitung → Erledigt → Entsorgt).
|
Statussystem für Fälle (Offen → In Bearbeitung → Entsorgt).
|
||||||
|
|
||||||
## 19. Prioritätssystem
|
- [x] ## 19. Prioritätssystem
|
||||||
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
|
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
|
||||||
|
|
||||||
## 20. Verantwortlichkeits-System
|
- [x] ## 20. Verantwortlichkeits-System
|
||||||
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden.
|
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden (Zuständig-Dropdown aus Appwrite-Benutzern der Filiale).
|
||||||
|
|
||||||
## 21. Kommentar-System
|
- [x] ## 21. Kommentar-System
|
||||||
Interne Kommentare und technische Notizen zu jedem Defektfall.
|
Interne Kommentare und technische Notizen zu jedem Defektfall (Kommentar-Feld, CommentPopup für Anzeige).
|
||||||
|
|
||||||
## 22. Defekt-Historie
|
- [x] ## 22. Defekt-Historie
|
||||||
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden.
|
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden (Audit-Log pro Asset).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 4 – SEARCH & FILTERING
|
# PRIORITY 4 – SEARCH & FILTERING
|
||||||
|
|
||||||
## 23. Erweiterte Suche
|
- [x] ## 23. Erweiterte Suche
|
||||||
Suche nach ERL-Nummer, Seriennummer, Artikelnummer oder Beschreibung.
|
Suche nach ERL-Nummer, Artikelnummer, Seriennummer, Defektbeschreibung.
|
||||||
|
|
||||||
## 24. Statusfilter
|
- [x] ## 24. Statusfilter
|
||||||
Filter für offene, in Bearbeitung befindliche, erledigte oder entsorgte Artikel.
|
Filter für offene, in Bearbeitung befindliche, entsorgte Artikel.
|
||||||
|
|
||||||
## 25. Prioritätsfilter
|
- [x] ## 25. Prioritätsfilter
|
||||||
Filter für kritische oder wichtige Fälle.
|
Filter/Sortierung nach Priorität (kritisch, hoch, mittel, niedrig).
|
||||||
|
|
||||||
## 26. Mitarbeiterfilter
|
- [x] ## 26. Mitarbeiterfilter
|
||||||
Anzeige der Fälle nach zuständigem Mitarbeiter.
|
Anzeige der Fälle nach zuständigem Mitarbeiter (Sortierung „Mir zugewiesen“).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 5 – STATISTICS & ANALYTICS
|
# PRIORITY 5 – STATISTICS & ANALYTICS
|
||||||
|
|
||||||
## 27. Mitarbeiterstatistiken
|
- [x] ## 27. Mitarbeiterstatistiken
|
||||||
Eigene offenen, erledigten und überfälligen Fälle eines Mitarbeiters.
|
Eigene offenen, erledigten und überfälligen Fälle (Filialleiter-Dashboard: Mitarbeiter-Performance mit Erledigungsrate).
|
||||||
|
|
||||||
## 28. Filialstatistiken
|
- [x] ## 28. Filialstatistiken
|
||||||
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
|
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
|
||||||
|
|
||||||
## 29. Unternehmensstatistiken
|
- [x] ## 29. Unternehmensstatistiken
|
||||||
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen.
|
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen (Firmenleiter-Dashboard).
|
||||||
|
|
||||||
## 30. Bearbeitungszeit-Analyse
|
- [ ] ## 30. Bearbeitungszeit-Analyse
|
||||||
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
|
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
|
||||||
|
|
||||||
## 31. Häufigste Defekte
|
- [ ] ## 31. Häufigste Defekte
|
||||||
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
|
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 6 – ORGANISATION STRUCTURE
|
# PRIORITY 6 – ORGANISATION STRUCTURE
|
||||||
|
|
||||||
## 32. Filial-System
|
- [x] ## 32. Filial-System
|
||||||
Unterstützung mehrerer Standorte innerhalb eines Unternehmens.
|
Unterstützung mehrerer Standorte innerhalb eines Unternehmens (locations-Collection, Admin verwaltet Filialen).
|
||||||
|
|
||||||
## 33. Standortzuweisung für Benutzer
|
- [x] ## 33. Standortzuweisung für Benutzer
|
||||||
Benutzer gehören zu einer bestimmten Filiale.
|
Benutzer gehören zu einer bestimmten Filiale (users_meta.locationId).
|
||||||
|
|
||||||
## 34. Standortfilter für Daten
|
- [x] ## 34. Standortfilter für Daten
|
||||||
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
|
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 7 – SYSTEM FEATURES
|
# PRIORITY 7 – SYSTEM FEATURES
|
||||||
|
|
||||||
## 35. Export-Funktion
|
- [x] ## 35. Export-Funktion
|
||||||
Datenexport für Berichte oder Archivierung.
|
Datenexport für Berichte oder Archivierung (JSON-Export im Header).
|
||||||
|
|
||||||
## 36. Import-Funktion
|
- [ ] ## 36. Import-Funktion
|
||||||
Import von Datensätzen für Migration oder Synchronisation.
|
Import von Datensätzen für Migration oder Synchronisation.
|
||||||
|
|
||||||
## 37. Druckansicht
|
- [x] ## 37. Druckansicht
|
||||||
Optimierte Druckansicht für Berichte oder Listen.
|
Optimierte Druckansicht für Berichte oder Listen (Drucken-Button in der Asset-Tabelle).
|
||||||
|
|
||||||
## 38. Benachrichtigungen
|
- [ ] ## 38. Benachrichtigungen
|
||||||
Systemmeldungen bei kritischen oder überfälligen Defektfällen.
|
Systemmeldungen bei kritischen oder überfälligen Defektfällen (Toasts vorhanden, keine gezielten Alerts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PRIORITY 8 – FUTURE FEATURES
|
# PRIORITY 8 – FUTURE FEATURES
|
||||||
|
|
||||||
## 39. Datei-Uploads
|
- [ ] ## 39. Datei-Uploads
|
||||||
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
|
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
|
||||||
|
|
||||||
## 40. Mobile Optimierung
|
- [x] ## 40. Mobile Optimierung
|
||||||
Optimierte Nutzung für Tablets oder mobile Geräte im Lager.
|
Optimierte Nutzung für Tablets oder mobile Geräte im Lager (responsive Layout, Sidebar ausgeblendet auf Mobile, Form unten).
|
||||||
|
|
||||||
## 41. API-Schnittstellen
|
- [ ] ## 41. API-Schnittstellen
|
||||||
Möglichkeit zur Integration mit anderen Systemen.
|
Möglichkeit zur Integration mit anderen Systemen.
|
||||||
|
|
||||||
## 42. Automatische Eskalationen
|
- [ ] ## 42. Automatische Eskalationen
|
||||||
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
|
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
22
vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user