actionsleiste update

This commit is contained in:
2026-04-07 17:12:32 +02:00
parent 2587238b3e
commit cbaba8cbe8
11 changed files with 1187 additions and 178 deletions

617
docs/VERKAUF_UEBERSICHT.md Normal file
View File

@@ -0,0 +1,617 @@
# DefektTrack — Produkt- und Infrastruktur-Dokumentation
> **Digitale Defekt- und Asset-Verwaltung für Unternehmen mit mehreren Filialen.**
> Echtzeit-Tracking defekter Ware, rollenbasierte Dashboards und transparente Auswertungen — alles in einer Anwendung.
---
## Inhaltsverzeichnis
1. [Produktübersicht](#1-produktübersicht)
2. [Architektur & Tech-Stack](#2-architektur--tech-stack)
3. [Datenmodell](#3-datenmodell)
4. [Rollenkonzept](#4-rollenkonzept--berechtigungsmatrix)
5. [Asset-Tracker — Liste & Filter](#5-asset-tracker--liste--filter)
6. [Admin-Panel](#6-admin-panel)
7. [Filialleiter-Dashboard](#7-filialleiter-dashboard)
8. [Firmenleiter-Dashboard](#8-firmenleiter-dashboard)
9. [Sicherheitskonzept](#9-sicherheitskonzept)
10. [Navigationsfluss](#10-navigationsfluss)
---
## 1. Produktübersicht
**DefektTrack** ist eine webbasierte Anwendung zur lückenlosen Erfassung, Verfolgung und Auswertung defekter Ware über mehrere Filialen hinweg.
### Welches Problem löst DefektTrack?
| Problem | Lösung |
|---------|--------|
| Defekte Ware wird auf Papier oder in Excel erfasst — unübersichtlich, fehleranfällig, nicht zentral | Zentrale Web-App mit Echtzeit-Synchronisation |
| Keine Transparenz über Standorte hinweg | Firmenweite Dashboards mit Filialvergleich |
| Zuständigkeiten unklar, Aufgaben gehen unter | Zuständigkeits-Zuweisung + Überfälligkeits-Tracking mit 7-Tage-Frist |
| Filialleiter haben keinen Überblick über Team-Performance | Mitarbeiter-Erledigungsraten + Trend-Analysen |
| Firmenleitung sieht keine Gesamtzahlen | Globales Dashboard mit KPIs und Filialvergleich |
### Kernfunktionen auf einen Blick
- Defekt-Erfassung mit ERL-Nummer, Seriennummer, Priorität, Kommentar und Datei-Anhängen
- Dreistufiger Status-Workflow: **Offen → In Bearbeitung → Entsorgt**
- Automatisches Überfälligkeits-Tracking (Frist: 7 Tage)
- 5 Spalten-Filter + 4 Sortieroptionen in der Asset-Tabelle
- Druck-Export (offene/in Bearbeitung) und JSON-Export
- Rollenbasierte Dashboards (Admin, Filialleiter, Firmenleiter)
- Mitarbeiter-Performance-Übersicht
- Audit-Log für alle Änderungen
- Dark Mode / Light Mode
- Responsive Design (Desktop + Mobile)
---
## 2. Architektur & Tech-Stack
### Technologie
| Schicht | Technologie | Version |
|---------|-------------|---------|
| **Frontend** | React | 19 |
| **Bundler** | Vite | 6 |
| **Styling** | Tailwind CSS | 4 |
| **UI-Komponenten** | shadcn/ui (Base UI) | 4 |
| **Charts** | Recharts | 2.15 |
| **Icons** | Lucide React | 0.577 |
| **Routing** | React Router | 7 |
| **Backend (BaaS)** | Appwrite | 21 (Client) / 22 (Server) |
| **API-Server** | Express | 5 |
| **Benachrichtigungen** | Sonner | 2 |
| **Theming** | next-themes | 0.4 |
### Architektur-Diagramm
```mermaid
flowchart TB
subgraph client [Browser]
SPA["React SPA"]
end
subgraph server [Server-Infrastruktur]
ExpressAPI["Express API\n(Admin-Operationen)"]
AppwriteServer["Appwrite Server"]
end
subgraph appwrite [Appwrite Services]
Auth["Authentication\n(E-Mail/Passwort)"]
TeamsService["Teams\n(Rollen)"]
DB["Database\n(5 Collections)"]
StorageSvc["Storage\n(Anhänge)"]
end
SPA -->|"REST API"| AppwriteServer
SPA -->|"/api/*"| ExpressAPI
ExpressAPI -->|"Server SDK"| AppwriteServer
AppwriteServer --> Auth
AppwriteServer --> TeamsService
AppwriteServer --> DB
AppwriteServer --> StorageSvc
```
### Deployment-Optionen
- **Self-hosted:** Appwrite als Docker-Container + Node.js-Server
- **Cloud:** Appwrite Cloud + beliebiges Node.js-Hosting (z.B. Railway, Render, VPS)
- **Statisches Frontend:** Vite-Build als statische Dateien auf beliebigem Webserver
---
## 3. Datenmodell
Die Appwrite-Datenbank (`defekttrack_db`) umfasst 5 Collections und einen Storage-Bucket:
### Collections
#### `locations` — Filialen
| Attribut | Typ | Pflicht | Beschreibung |
|----------|-----|---------|--------------|
| `name` | String (128) | Ja | Filialname (z.B. „Kaiserslautern") |
| `address` | String (256) | Nein | Adresse der Filiale |
| `isActive` | Boolean | Nein | Filiale aktiv/inaktiv (Standard: true) |
**Berechtigungen:** Lesen: alle authentifizierten Benutzer. Schreiben/Löschen: nur Admin.
#### `users_meta` — Benutzerprofile
| Attribut | Typ | Pflicht | Beschreibung |
|----------|-----|---------|--------------|
| `userId` | String (64) | Ja | Appwrite User-ID |
| `locationId` | String (64) | Nein | Zugeordnete Filiale |
| `userName` | String (128) | Nein | Anzeigename |
| `role` | String (32) | Ja | Rolle (admin, firmenleiter, filialleiter, service, lager) |
| `mustChangePassword` | Boolean | Nein | Passwortänderung beim nächsten Login erforderlich |
**Berechtigungen:** Lesen: alle. Erstellen/Löschen: nur Admin. Aktualisieren: alle.
#### `lagerstandorte` — Lagerstandorte pro Filiale
| Attribut | Typ | Pflicht | Beschreibung |
|----------|-----|---------|--------------|
| `name` | String (128) | Ja | Name des Lagerstandorts |
| `locationId` | String (64) | Ja | Zugehörige Filiale |
| `isActive` | Boolean | Nein | Aktiv/Inaktiv (Standard: true) |
**Berechtigungen:** Lesen: alle. CRUD: Admin + Filialleiter.
#### `assets` — Defekt-Einträge
| Attribut | Typ | Pflicht | Beschreibung |
|----------|-----|---------|--------------|
| `erlNummer` | String (64) | Ja | ERL-Nummer (eindeutige Kennung) |
| `seriennummer` | String (128) | Ja | Seriennummer des Artikels |
| `artikelNr` | String (64) | Nein | Artikelnummer |
| `bezeichnung` | String (256) | Nein | Artikelbezeichnung |
| `defekt` | String (1024) | Nein | Defektbeschreibung |
| `lagerstandortId` | String (64) | Nein | Zugeordneter Lagerstandort |
| `zustaendig` | String (128) | Ja | Zuständiger Mitarbeiter |
| `status` | String (32) | Ja | `offen` / `in_bearbeitung` / `entsorgt` |
| `prio` | String (16) | Ja | `kritisch` / `hoch` / `mittel` / `niedrig` |
| `bearbeitungsStatus` | String (64) | Nein | Bearbeitungs-Unterstatus bei "In Bearbeitung" (`portalpruefung` / `gutschreiben_entsorgen` / `zurueck_hersteller` / `defekt_ankunft`) |
| `kommentar` | String (8192) | Nein | Kommentar inkl. Anhang-Marker |
| `createdBy` | String (128) | Nein | Erstellt von |
| `lastEditedBy` | String (128) | Nein | Zuletzt bearbeitet von |
**Berechtigungen:** Lesen/Erstellen/Aktualisieren: alle. Löschen: Admin + Filialleiter.
#### `audit_logs` — Änderungsprotokoll
| Attribut | Typ | Pflicht | Beschreibung |
|----------|-----|---------|--------------|
| `assetId` | String (64) | Ja | Referenz auf Asset |
| `action` | String (64) | Ja | Aktion (z.B. „erstellt", „status_geaendert") |
| `details` | String (2048) | Nein | Beschreibung der Änderung |
| `userId` | String (64) | Ja | Wer hat die Änderung vorgenommen |
| `userName` | String (128) | Ja | Name des Benutzers |
**Berechtigungen:** Lesen + Erstellen: alle authentifizierten Benutzer.
### Storage
| Bucket | Max. Dateigröße | Erlaubte Formate | Verwendung |
|--------|-----------------|------------------|------------|
| `defekttrack_anhaenge` | 15 MB | JPG, PNG, GIF, WebP, PDF | Kommentar-Anhänge mit Bildvorschau |
---
## 4. Rollenkonzept & Berechtigungsmatrix
DefektTrack nutzt **5 Rollen**, die über Appwrite Teams zugewiesen werden. Bei Mehrfach-Mitgliedschaft gilt die Rolle mit der höchsten Priorität.
### Rollenhierarchie
```mermaid
flowchart TD
Admin["Admin\n(Volle Kontrolle)"]
Firmenleiter["Firmenleiter\n(Unternehmensweite Sicht)"]
Filialleiter["Filialleiter\n(Filial-Management)"]
Service["Service\n(Operativ)"]
Lager["Lager\n(Operativ)"]
Admin --- Firmenleiter
Firmenleiter --- Filialleiter
Filialleiter --- Service
Filialleiter --- Lager
```
### Rollenübersicht
| Rolle | Startseite | Beschreibung |
|-------|------------|--------------|
| **Admin** | `/admin` | Systemadministrator mit voller Kontrolle über Filialen, Benutzer und Daten |
| **Firmenleiter** | `/firmenleiter` | Geschäftsführung mit unternehmensweitem Überblick über alle Filialen |
| **Filialleiter** | `/filialleiter` | Filialleitung mit detailliertem Einblick in die eigene Filiale + Vergleich |
| **Service** | `/tracker` | Operative Kraft — erfasst und bearbeitet Defekte im Tagesgeschäft |
| **Lager** | `/tracker` | Operative Kraft — identisch mit Service, arbeitet im Defekt-Tracker |
### Detaillierte Berechtigungsmatrix
| Funktion | Admin | Firmenleiter | Filialleiter | Service | Lager |
|----------|:-----:|:------------:|:------------:|:-------:|:-----:|
| **Defekt-Tracker verwenden** | Ja | Ja | Ja | Ja | Ja |
| **Assets erfassen** | Ja | Ja | Ja | Ja | Ja |
| **Assets Status ändern** | Ja | Ja | Ja | Ja | Ja |
| **Assets bearbeiten** | Ja | Ja | Ja | Ja | Ja |
| **Assets löschen** | Ja | — | Ja | — | — |
| **JSON-Export** | Ja | Ja | Ja | Ja | Ja |
| **Drucken** | Ja | Ja | Ja | Ja | Ja |
| **Admin-Panel** | Ja | — | — | — | — |
| **Filialen verwalten (CRUD)** | Ja | — | — | — | — |
| **Benutzer anlegen/verwalten** | Ja | — | — | — | — |
| **Rollen zuweisen** | Ja | — | — | — | — |
| **Benutzer-Details einsehen** | Ja | Ja | Nur eigene Filiale | — | — |
| **Benutzer-Details bearbeiten** | Ja | Ja | — (nur Lesen) | — | — |
| **Firmenleiter-Dashboard** | Ja | Ja | — | — | — |
| **Filialleiter-Dashboard** | Ja | — | Ja | — | — |
| **Lagerstandorte verwalten** | Ja | — | Ja | — | — |
| **Audit-Log einsehen** | Ja | Ja | Ja | Ja | Ja |
### Was jede Rolle im Detail kann
#### Admin — Volle Systemkontrolle
- Zugang zu **allen Bereichen** der Anwendung (Admin-Panel, alle Dashboards, Tracker)
- Filialen anlegen, bearbeiten, aktivieren/deaktivieren und löschen
- Benutzer anlegen mit Rollenzuweisung und Filialzuordnung
- Benutzerprofile einsehen und bearbeiten (Name, Rolle, Filiale)
- Assets löschen
- Lagerstandorte verwalten
- Überblick über System-KPIs: Benutzeranzahl, Filialanzahl, Assets, Lagerstandorte, Filialen ohne Filialleiter
#### Firmenleiter — Strategischer Unternehmensüberblick
- **Firmenleiter-Dashboard** mit globalen Kennzahlen über alle Filialen
- Überblick: Anzahl Filialen, Mitarbeiter gesamt, Assets gesamt, globale Erledigungsrate
- Status-Übersicht: Offen / In Bearbeitung / Erledigt (firmenweit)
- Filialkarten mit Kennzahlen pro Standort (Mitarbeiter, Lagerstandorte, Assets)
- Benutzer-Details einsehen und bearbeiten
- Defekt-Tracker für operative Arbeit
#### Filialleiter — Detailliertes Filial-Management
- **Filialleiter-Dashboard** mit umfangreichen Analysen der eigenen Filiale
- **Gesamtübersicht:** Donut-Diagramm der Status-Verteilung + Tabelle überfälliger Einträge
- **Tages-Analyse:** Tages-Donut, 7-Tage-Balkendiagramm (Erfasst/Erledigt/Überfällig), Vergleich zum Vortag
- **Monats-Analyse:** Monats-Donut, 6-Monate-Balkendiagramm, Vergleich zum Vormonat
- **Filialvergleich:** Durchschnittswerte anderer Filialen als Benchmark
- **Mitarbeiter-Performance:** Pro Mitarbeiter: zugewiesene Assets, offen, in Bearbeitung, erledigt, Erledigungsrate (%) mit Fortschrittsbalken
- Navigation zu Mitarbeiter-Details (nur Lesen, nur eigene Filiale)
- Lagerstandorte für eigene Filiale verwalten
- Assets löschen
#### Service & Lager — Operative Tagesarbeit
- **Defekt-Tracker** als Hauptarbeitsbereich
- Neue Defekte erfassen: ERL-Nr., Seriennummer, Artikelnr., Bezeichnung, Defekt, Priorität, Lagerstandort, Zuständiger, Kommentar mit Datei-Anhängen
- Status-Workflow: Offen → In Bearbeitung → Entsorgt (zyklisch)
- Filter und Sortierung in der Asset-Tabelle
- Asset-Detailansicht zum Bearbeiten
- Kommentar-/Anhang-Popup zum schnellen Einsehen
- Druckfunktion und JSON-Export
---
## 5. Asset-Tracker — Liste & Filter
Der **Defekt-Tracker** (`/tracker`) ist die zentrale Arbeitsansicht für alle Benutzer. Er besteht aus drei Bereichen:
### Layout
```
┌────────────────────────────────────────────────────────────┐
│ Header (Navigation, Export, Benutzer-Menü) │
├──────────────┬─────────────────────────────────────────────┤
│ │ Dashboard-Karten (6 Karten) │
│ Erfassungs- │─────────────────────────────────────────────│
│ Formular │ │
│ (Sidebar) │ Asset-Tabelle │
│ │ (Filter, Sortierung, Aktionen) │
│ │ │
└──────────────┴─────────────────────────────────────────────┘
```
### Dashboard-Karten (Status-Filter)
6 klickbare Karten, die gleichzeitig als Filter dienen:
| Karte | Farbe | Anzeige | Filter-Effekt |
|-------|-------|---------|---------------|
| **Offen** | Rot | Anzahl Assets mit Status „offen" | Zeigt nur offene Assets |
| **In Bearbeitung** | Amber | Anzahl Assets mit Status „in_bearbeitung" | Zeigt nur Assets in Bearbeitung |
| **Entsorgt** | Grau | Anzahl Assets mit Status „entsorgt" | Zeigt nur entsorgte Assets |
| **12 Tage überfällig** | Blau | Assets 12 Tage über der 7-Tage-Frist | Filtert auf diese Gruppe |
| **34 Tage überfällig** | Indigo | Assets 34 Tage über der 7-Tage-Frist | Filtert auf diese Gruppe |
| **5+ Tage überfällig** | Violett | Assets 5+ Tage über der 7-Tage-Frist | Filtert auf diese Gruppe |
Erneutes Klicken auf eine aktive Karte entfernt den Filter.
### Asset-Tabelle — Spalten
| Spalte | Inhalt | Besonderheiten |
|--------|--------|----------------|
| **ERL-Nr.** | ERL-Nummer | Zellhintergrund farbig nach Priorität (Kritisch=Rot, Hoch=Orange, Mittel=Gelb, Niedrig=Grün) |
| **Artikel** | Artikelnummer + Bezeichnung | Zweizeilig: Nummer fett, Bezeichnung klein |
| **Seriennr.** | Seriennummer | Monospace-Schrift |
| **Defekt** | Defektbeschreibung | Max. 180px Breite, Textabschneidung |
| **Standort** | Name des Lagerstandorts | Aufgelöst aus Lagerstandort-ID |
| **Status** | Offen / In Bearbeitung / Entsorgt | Farbige Badges |
| **Alter** | Tage seit Erfassung | „Heute", „1 Tag", „n Tage" + „Überfällig!"-Warnung ab 7 Tagen |
| **Aktionen** | 4 Buttons im 2x2-Grid | Status ändern, Bearbeiten, Info-Popup, Zuständiger |
### Spalten-Filter (5 Stück)
| Filter | Typ | Funktionsweise |
|--------|-----|----------------|
| **ERL-Nr.** | Textsuche | Teilstring-Suche, case-insensitive |
| **Artikel** | Textsuche | Sucht in Artikelnummer ODER Bezeichnung |
| **Seriennr.** | Textsuche | Teilstring-Suche, case-insensitive |
| **Defekt** | Textsuche | Teilstring-Suche, case-insensitive |
| **Standort** | Dropdown | Auswahl eines spezifischen Lagerstandorts |
### Sortierung (4 Optionen)
| Option | Verhalten |
|--------|-----------|
| **Priorität** (Standard) | Kritisch → Hoch → Mittel → Niedrig |
| **Neueste zuerst** | Nach Erstellungsdatum absteigend |
| **Älteste zuerst** | Nach Erstellungsdatum aufsteigend |
| **Mir zugewiesen** | Nur eigene Assets, sortiert nach Priorität |
### Status-Workflow
```mermaid
stateDiagram-v2
[*] --> Offen: Asset erfasst
Offen --> InBearbeitung: In Bearbeitung nehmen
InBearbeitung --> Entsorgt: Entsorgen
Entsorgt --> Offen: Neu öffnen
state "In Bearbeitung" as InBearbeitung
```
- Überfälligkeit wird automatisch erkannt: Status „Offen" oder „In Bearbeitung" **und** älter als 7 Tage
- Überfällige Zeilen werden optisch hervorgehoben (amber-Hintergrund + linker Rand)
### Zeilen-Aktionen (2x2-Grid pro Zeile)
| Element | Funktion |
|---------|----------|
| **Status-Dropdown** | Dropdown-Menü zur Auswahl des neuen Status (Offen / In Bearbeitung / Entsorgt). Bei Status != "In Bearbeitung" nimmt das Dropdown die volle Breite ein. |
| **Bearbeitungsstatus** | Nur sichtbar bei Status "In Bearbeitung" — Dropdown mit 4 Optionen: Portalprüfung durchführen, Direkt gutschreiben & entsorgen, Zurück an Hersteller senden, Defekt bei Ankunft melden |
| **Info** | Navigation zur Asset-Detailseite (Bearbeiten-Ansicht) |
| **Zuständig** | Anzeige des zuständigen Mitarbeiters (nur Lesen) |
### Erfassungs-Formular (Sidebar)
Neue Defekte werden über die linke Sidebar erfasst:
- ERL-Nummer, Seriennummer (Pflicht)
- Artikelnummer, Bezeichnung
- Defektbeschreibung
- Priorität (Kritisch / Hoch / Mittel / Niedrig)
- Lagerstandort (Dropdown der aktiven Standorte)
- Zuständiger Mitarbeiter (Dropdown der Kollegen)
- Kommentar mit optionalem Betreff
- Datei-Anhänge (Bilder, PDFs — max. 15 MB, mit Vorschau)
### Export & Druck
| Funktion | Beschreibung |
|----------|--------------|
| **Drucken** | Öffnet druckoptimiertes Fenster mit allen offenen/in Bearbeitung befindlichen Assets (ERL-Nr., Seriennummer, Defekt, Priorität) |
| **JSON-Export** | Exportiert alle geladenen Assets als JSON-Datei mit Zeitstempel im Dateinamen |
### Kommentar-System
- Kommentare mit optionalem **Betreff** (markiert mit `*...*`)
- **Datei-Anhänge** werden als Marker im Kommentar-Feld gespeichert
- Anhänge werden im Appwrite Storage abgelegt
- Bilder: Authentifizierte Vorschau direkt in der App
- PDFs und andere Dateien: Download-Link
- Einsehbar über das **Info-Popup** in der Tabelle und auf der **Asset-Detailseite**
---
## 6. Admin-Panel
Das **Admin-Panel** (`/admin`) ist die Verwaltungszentrale für Systemadministratoren.
### KPI-Karten (5 Stück)
| KPI | Beschreibung |
|-----|--------------|
| **Benutzer** | Gesamtzahl registrierter Benutzer |
| **Filialen** | Anzahl angelegter Filialen |
| **Assets gesamt** | Gesamtzahl aller erfassten Defekt-Einträge |
| **Lagerstandorte** | Anzahl aller Lagerstandorte über alle Filialen |
| **Filialen ohne Filialleiter** | Warnung: Filialen, denen kein Filialleiter zugeordnet ist |
### Filial-Verwaltung
| Aktion | Beschreibung |
|--------|--------------|
| **Filiale anlegen** | Name + Adresse eingeben, wird sofort aktiv |
| **Filiale bearbeiten** | Name und Adresse ändern, inkl. Filialdetail-Ansicht |
| **Aktivieren/Deaktivieren** | Filiale ein-/ausschalten ohne Datenverlust |
| **Filiale löschen** | Endgültiges Entfernen (mit Bestätigung) |
Beim Bearbeiten einer Filiale öffnet sich die **Filialdetail-Ansicht** mit erweiterten Verwaltungsoptionen (z.B. Lagerstandorte der Filiale, zugeordnete Benutzer).
### Benutzerverwaltung
| Funktion | Beschreibung |
|----------|--------------|
| **Benutzersuche** | Echtzeit-Suche nach Name oder User-ID |
| **Benutzerliste** | Scrollbare Liste mit Name, Filiale und Rollen-Badge |
| **Benutzer-Detail** | Klick öffnet Detailansicht mit Name, Rolle, Filialzuordnung |
| **Neuer Benutzer** | Formular zum Anlegen: E-Mail, Name, Passwort, Rolle, Filiale |
| **Verfügbare Rollen** | Filialleiter, Service, Lager, Firmenleiter (Admin nur per Setup) |
---
## 7. Filialleiter-Dashboard
Das **Filialleiter-Dashboard** (`/filialleiter`) bietet Filialleitern eine umfassende Analyse ihrer Filiale mit Vergleichswerten.
### Bereich 1: Aktuelle Gesamtübersicht
| Element | Beschreibung |
|---------|--------------|
| **Donut-Diagramm** | Verteilung der Assets nach Status: Offen, In Bearbeitung, Erledigt, Überfällig |
| **Interaktive Legende** | Hover über Legende hebt Sektor hervor, zeigt absolute Zahl + Prozent + Balken |
| **Zentraler Wert** | Gesamtzahl der Assets der Filiale im Donut-Zentrum |
| **Überfällige-Tabelle** | Sortierte Liste aller überfälligen Einträge mit ERL-Nr., Artikel, Status, Tage überfällig, Zuständiger |
Klick auf eine überfällige Zeile navigiert direkt zur Asset-Detailseite.
### Bereich 2: Tages-Ansicht
| Element | Beschreibung |
|---------|--------------|
| **Donut „Meine Filiale"** | Tagesaktivität (erfasst, in Bearbeitung, erledigt, überfällig) |
| **Tages-Kennzahl** | „Heute erfasst" mit Trend-Pfeil im Vergleich zum Vortag |
| **7-Tage-Balkendiagramm** | Pro Tag: Erfasst, Erledigt, Überfällig als gruppierte Balken |
| **Donut „Durchschnitt andere Filialen"** | Gleiche Metrik, gemittelt über alle anderen Filialen als Benchmark |
### Bereich 3: Monats-Ansicht
| Element | Beschreibung |
|---------|--------------|
| **Donut „Meine Filiale"** | Monatliche Status-Verteilung |
| **Monats-Kennzahl** | „Diesen Monat" mit Trend-Pfeil im Vergleich zum Vormonat |
| **6-Monate-Balkendiagramm** | Pro Monat: Erfasst, Erledigt, Überfällig als gruppierte Balken |
| **Donut „Durchschnitt andere Filialen"** | Monats-Durchschnitt aller anderen Filialen |
### Bereich 4: Mitarbeiter-Performance
Tabelle mit allen Mitarbeitern der Filiale:
| Spalte | Beschreibung |
|--------|--------------|
| **Mitarbeiter** | Name (klickbar → Mitarbeiter-Detail) |
| **Zugewiesen** | Gesamtzahl zugewiesener Assets |
| **Offen** | Anzahl offener Assets |
| **In Bearbeitung** | Anzahl in Bearbeitung |
| **Erledigt** | Anzahl erledigter Assets |
| **Erledigungsrate** | Prozent + visueller Fortschrittsbalken |
Sortiert nach Erledigungsrate (höchste zuerst).
---
## 8. Firmenleiter-Dashboard
Das **Firmenleiter-Dashboard** (`/firmenleiter`) bietet der Geschäftsführung einen strategischen Überblick über das gesamte Unternehmen.
### Globale KPI-Karten (4 Stück)
| KPI | Icon | Beschreibung |
|-----|------|--------------|
| **Filialen** | Gebäude | Gesamtzahl aller Filialen |
| **Mitarbeiter gesamt** | Personen | Alle registrierten Benutzer |
| **Assets gesamt** | Paket | Alle erfassten Defekt-Einträge |
| **Erledigungsrate** | Häkchen | Prozent der erledigten Assets (`entsorgt / gesamt * 100`) |
### Status-Übersicht (3 Karten)
| Status | Farbe | Beschreibung |
|--------|-------|--------------|
| **Offen** | Rot | Firmenweit offene Assets |
| **In Bearbeitung** | Amber | Firmenweit in Bearbeitung |
| **Erledigt** | Grün | Firmenweit erledigte Assets |
### Filial-Grid
Pro Filiale eine Karte mit:
| Element | Beschreibung |
|---------|--------------|
| **Filialname** | Name der Filiale |
| **Adresse** | Optional angezeigte Adresse |
| **Status-Badge** | Aktiv / Inaktiv |
| **Mitarbeiter** | Anzahl zugeordneter Mitarbeiter |
| **Lagerstandorte** | Anzahl der Lagerstandorte |
| **Assets** | Gesamtzahl der Assets |
---
## 9. Sicherheitskonzept
### Authentifizierung
- **Appwrite Authentication** mit E-Mail/Passwort-Login
- Session-basiert mit automatischer Session-Prüfung beim App-Start
- Geschützte Routen: Nicht-eingeloggte Benutzer werden zu `/login` umgeleitet
### Autorisierung — Mehrstufiges Konzept
| Schicht | Mechanismus | Beschreibung |
|---------|-------------|--------------|
| **1. Appwrite Teams** | Team-Mitgliedschaft | Jede Rolle = ein Team. Effektive Rolle per Prioritätslogik (Admin > Firmenleiter > Filialleiter > Service > Lager) |
| **2. Collection Permissions** | Appwrite-interne Berechtigung | Pro Collection definiert, wer lesen/erstellen/aktualisieren/löschen darf |
| **3. Frontend-Navigation** | UI-basierte Einschränkung | Rollen-abhängige Menüpunkte im Header |
| **4. Admin-API** | Shared Secret | Express-API für Benutzerverwaltung, geschützt durch `X-Admin-Secret` Header |
### Daten-Isolation
- **Filialbasiert:** Benutzer mit zugewiesener Filiale sehen nur Assets, deren Lagerstandort zur eigenen Filiale gehört
- **Filialleiter:** Kann Mitarbeiter-Details nur für die eigene Filiale einsehen
- **Audit-Trail:** Jede Asset-Änderung (Erstellen, Status-Wechsel) wird protokolliert mit Benutzer, Aktion und Zeitstempel
### Collection-Berechtigungsmatrix
| Collection | Lesen | Erstellen | Aktualisieren | Löschen |
|------------|:-----:|:---------:|:-------------:|:-------:|
| `locations` | Alle | Admin | Admin | Admin |
| `users_meta` | Alle | Admin | Alle | Admin |
| `lagerstandorte` | Alle | Admin, Filialleiter | Admin, Filialleiter | Admin, Filialleiter |
| `assets` | Alle | Alle | Alle | Admin, Filialleiter |
| `audit_logs` | Alle | Alle | — | — |
---
## 10. Navigationsfluss
### Rollenbasierte Weiterleitung nach Login
```mermaid
flowchart TD
Login["Login-Seite\n/login"] --> AuthCheck{"Authentifizierung\nerfolgreich?"}
AuthCheck -->|Nein| Login
AuthCheck -->|Ja| RoleCheck{"Rolle prüfen"}
RoleCheck -->|admin| AdminPanel["/admin\nAdmin-Panel"]
RoleCheck -->|firmenleiter| FirmenDash["/firmenleiter\nFirmenleiter-Dashboard"]
RoleCheck -->|filialleiter| FilialDash["/filialleiter\nFilialleiter-Dashboard"]
RoleCheck -->|"service / lager"| Tracker["/tracker\nDefektTracker"]
```
### Vollständiger Navigationsfluss
```mermaid
flowchart TD
AdminPanel["/admin\nAdmin-Panel"]
FirmenDash["/firmenleiter\nFirmenleiter-Dashboard"]
FilialDash["/filialleiter\nFilialleiter-Dashboard"]
Tracker["/tracker\nDefektTracker"]
AssetDetail["/asset/:id\nAsset-Detail"]
UserDetail["/admin/user/:id\nBenutzer-Detail"]
MitarbeiterDetail["/filialleiter/mitarbeiter/:id\nMitarbeiter-Detail"]
AdminPanel -->|"Nav: DefektTrack"| Tracker
AdminPanel -->|"Nav: Filialleiter"| FilialDash
AdminPanel -->|"Nav: Firmenleiter"| FirmenDash
AdminPanel -->|"Benutzer-Klick"| UserDetail
FirmenDash -->|"Nav: DefektTrack"| Tracker
FirmenDash -->|"Nav: Filialleiter"| FilialDash
FilialDash -->|"Nav: DefektTrack"| Tracker
FilialDash -->|"Überfällig-Klick"| AssetDetail
FilialDash -->|"Mitarbeiter-Klick"| MitarbeiterDetail
Tracker -->|"Bearbeiten-Klick"| AssetDetail
```
### Zugängliche Seiten pro Rolle
| Seite | Admin | Firmenleiter | Filialleiter | Service | Lager |
|-------|:-----:|:------------:|:------------:|:-------:|:-----:|
| `/admin` | Ja | — | — | — | — |
| `/firmenleiter` | Ja | Ja | — | — | — |
| `/filialleiter` | Ja | — | Ja | — | — |
| `/tracker` | Ja | Ja | Ja | Ja | Ja |
| `/asset/:id` | Ja | Ja | Ja | Ja | Ja |
| `/admin/user/:id` | Ja | Ja | — | — | — |
| `/filialleiter/mitarbeiter/:id` | — | — | Ja | — | — |
---
> **DefektTrack** — Transparenz schaffen. Defekte tracken. Prozesse optimieren.

View File

@@ -0,0 +1,56 @@
import { Client, Databases } 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 COLLECTION_ID = 'assets';
async function migrate() {
console.log('Migration: Füge bearbeitungsStatus Feld zur assets Collection hinzu...\n');
try {
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'bearbeitungsStatus', 64, false, '');
console.log('Attribut "bearbeitungsStatus" erfolgreich erstellt.');
console.log('Bitte warte ca. 5 Sekunden, bis Appwrite das Attribut aktiviert hat.');
} catch (err) {
if (err.code === 409 || err.message?.includes('already exists')) {
console.log('Attribut "bearbeitungsStatus" existiert bereits nichts zu tun.');
} else {
console.error('Fehler beim Erstellen des Attributs:', err.message || err);
process.exit(1);
}
}
}
migrate();

View File

@@ -183,10 +183,11 @@ async function createAssetsCollection() {
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, 'bearbeitungsStatus', 64, false, '');
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'kommentar', 8192, 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)');
console.log(' Attribute fuer assets erstellt (erlNummer, seriennummer, artikelNr, bezeichnung, defekt, lagerstandortId, zustaendig, status, prio, bearbeitungsStatus, kommentar, createdBy, lastEditedBy)');
}
async function createAuditLogsCollection() {

View File

@@ -28,6 +28,14 @@ const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorg
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'];
const BEARB_STATUS_LABELS = {
'': 'Nicht gesetzt',
portalpruefung: '\u{1F50D} Portalprüfung durchführen',
gutschreiben_entsorgen: '\u267B\uFE0F Direkt gutschreiben & entsorgen',
zurueck_hersteller: '\u{1F4E6} Zurück an Hersteller senden',
defekt_ankunft: '\u26A0\uFE0F Defekt bei Ankunft melden',
};
const BEARB_STATUS_OPTIONS = ['', 'portalpruefung', 'gutschreiben_entsorgen', 'zurueck_hersteller', 'defekt_ankunft'];
function formatTimestamp(ts) {
if (!ts) return '';
@@ -97,6 +105,7 @@ export default function AssetDetail() {
zustaendig: doc.zustaendig || '',
status: doc.status || 'offen',
prio: doc.prio || 'mittel',
bearbeitungsStatus: doc.bearbeitungsStatus || '',
kommentar: doc.kommentar || '',
});
} catch (err) {
@@ -125,6 +134,7 @@ export default function AssetDetail() {
{ key: 'zustaendig', label: 'Zuständig' },
{ key: 'status', label: 'Status' },
{ key: 'prio', label: 'Priorität' },
{ key: 'bearbeitungsStatus', label: 'Bearbeitungsstatus' },
{ key: 'kommentar', label: 'Kommentar' },
];
const changes = [];
@@ -134,6 +144,8 @@ export default function AssetDetail() {
if (oldVal !== newVal) {
if (f.key === 'status') {
changes.push(`${f.label}: ${STATUS_LABEL[oldVal] || oldVal}${STATUS_LABEL[newVal] || newVal}`);
} else if (f.key === 'bearbeitungsStatus') {
changes.push(`${f.label}: ${BEARB_STATUS_LABELS[oldVal] || oldVal || 'Nicht gesetzt'}${BEARB_STATUS_LABELS[newVal] || newVal || 'Nicht gesetzt'}`);
} else if (f.key === 'prio') {
changes.push(`${f.label}: ${PRIO_LABELS[oldVal] || oldVal}${PRIO_LABELS[newVal] || newVal}`);
} else {
@@ -201,6 +213,7 @@ export default function AssetDetail() {
zustaendig: asset.zustaendig || '',
status: asset.status || 'offen',
prio: asset.prio || 'mittel',
bearbeitungsStatus: asset.bearbeitungsStatus || '',
kommentar: asset.kommentar || '',
});
}
@@ -369,6 +382,26 @@ export default function AssetDetail() {
)}
</div>
{(form.status === 'in_bearbeitung' || asset.bearbeitungsStatus) && (
<div className="space-y-1.5 sm:col-span-2">
<Label>Bearbeitungsstatus</Label>
{editing ? (
<Select value={form.bearbeitungsStatus || '_none'} onValueChange={(v) => setForm(f => ({ ...f, bearbeitungsStatus: v === '_none' ? '' : v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEARB_STATUS_OPTIONS.map((s) => (
<SelectItem key={s || '_none'} value={s || '_none'}>{BEARB_STATUS_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{BEARB_STATUS_LABELS[asset.bearbeitungsStatus || ''] || ''}</p>
)}
</div>
)}
<div className="space-y-1.5 sm:col-span-2">
<Label>Kommentar</Label>
{editing ? (

View File

@@ -15,7 +15,7 @@ export default function CommentPopup({ artikel, onClose }) {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogContent className="sm:max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
<DialogDescription className="sr-only">
@@ -23,7 +23,7 @@ export default function CommentPopup({ artikel, onClose }) {
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-3 overflow-y-auto min-h-0 flex-1">
{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}

View File

@@ -1,49 +1,67 @@
import { isOverdue } from '../hooks/useAssets';
import { isOverdue, getDaysOld } from '../hooks/useAssets';
import { Card, CardContent } from '@/components/ui/card';
const STAT_CARDS = [
const STATUS_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)' },
];
const OVERDUE_CARDS = [
{ key: 'overdue_2', color: '#3B82F6', label: '12 Tage überfällig' },
{ key: 'overdue_4', color: '#6366F1', label: '34 Tage überfällig' },
{ key: 'overdue_6', color: '#7C3AED', label: '5+ Tage überfällig' },
];
function getOverdueDays(a) {
const created = a.$createdAt || a.erstelltAm;
return getDaysOld(created) - 7;
}
export default function Dashboard({ assets, statusFilter, onStatusFilterChange }) {
const overdueAssets = assets.filter(isOverdue);
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,
overdue_2: overdueAssets.filter((a) => { const d = getOverdueDays(a); return d >= 1 && d <= 2; }).length,
overdue_4: overdueAssets.filter((a) => { const d = getOverdueDays(a); return d >= 3 && d <= 4; }).length,
overdue_6: overdueAssets.filter((a) => { const d = getOverdueDays(a); return d >= 5; }).length,
};
const handleCardClick = (key) => {
onStatusFilterChange?.(statusFilter === key ? null : key);
};
const renderCard = ({ key, color, label }) => {
const isSelected = statusFilter === key;
return (
<Card
key={key}
className="py-0 cursor-pointer transition-all duration-200 hover:opacity-90"
style={{
borderTop: `3px solid ${color}`,
...(isSelected && { backgroundColor: `${color}30` }),
}}
onClick={() => handleCardClick(key)}
>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className={`text-sm mt-1 ${isSelected ? 'text-foreground/90' : 'text-muted-foreground'}`}>{label}</p>
</CardContent>
</Card>
);
};
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{STAT_CARDS.map(({ key, color, label }) => {
const isSelected = statusFilter === key;
return (
<Card
key={key}
className="py-0 cursor-pointer transition-all duration-200 hover:opacity-90"
style={{
borderTop: `3px solid ${color}`,
...(isSelected && {
backgroundColor: `${color}30`,
}),
}}
onClick={() => handleCardClick(key)}
>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className={`text-sm mt-1 ${isSelected ? 'text-foreground/90' : 'text-muted-foreground'}`}>{label}</p>
</CardContent>
</Card>
);
})}
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
{STATUS_CARDS.map(renderCard)}
</div>
<div className="grid grid-cols-3 gap-3">
{OVERDUE_CARDS.map(renderCard)}
</div>
</div>
);
}

View File

@@ -1,24 +1,34 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { hasInfoContent } from '@/lib/kommentarAnhaenge';
import { useAuth } from '../context/AuthContext';
import CommentPopup from './CommentPopup';
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
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';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Printer, Package, ChevronDown } 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 STATUS_DROPDOWN_OPTIONS = [
{ value: 'offen', label: 'Offen' },
{ value: 'in_bearbeitung', label: 'In Bearbeitung' },
{ value: 'entsorgt', label: 'Entsorgt' },
];
const BEARBEITUNGS_OPTIONS = [
{ value: 'portalpruefung', label: '\u{1F50D} Portalprüfung durchführen' },
{ value: 'gutschreiben_entsorgen', label: '\u267B\uFE0F Direkt gutschreiben & entsorgen' },
{ value: 'zurueck_hersteller', label: '\u{1F4E6} Zurück an Hersteller senden' },
{ value: 'defekt_ankunft', label: '\u26A0\uFE0F Defekt bei Ankunft melden' },
];
/** Prioritätsfarbe füllt die komplette ERL-Nr.-Zelle als Hintergrund */
const PRIO_ERL_CELL = {
@@ -41,12 +51,6 @@ const STATUS_BADGE_CONFIG = {
entsorgt: { variant: 'secondary' },
};
const STATUS_BUTTON_CONFIG = {
offen: { variant: 'destructive', className: '' },
in_bearbeitung: { variant: 'default', className: 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-800/40' },
entsorgt: { variant: 'secondary', className: '' },
};
function resolveStandortName(asset, lagerstandorte) {
if (!asset.lagerstandortId) return '';
const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId);
@@ -59,11 +63,10 @@ const STATUS_FILTER_MAP = { offen: 'offen', bearbeitung: 'in_bearbeitung', entso
const ENTITY_ROW_FRAME =
'[&>td]:border-t [&>td]:border-b [&>td]:border-border [&>td:first-child]:border-l [&>td:first-child]:rounded-l-md [&>td:last-child]:border-r [&>td:last-child]:rounded-r-md';
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte, statusFilter }) {
export default function DefektTable({ assets, onChangeStatus, onBearbeitungsStatus, showToast, lagerstandorte, statusFilter }) {
const { user } = useAuth();
const navigate = useNavigate();
const [activeFilter, setActiveFilter] = useState(null);
const [commentAsset, setCommentAsset] = useState(null);
const [filters, setFilters] = useState({
erlNummer: '',
@@ -97,7 +100,13 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
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 (statusFilter === 'overdue') {
if (statusFilter && statusFilter.startsWith('overdue_')) {
if (!isOverdue(a)) return false;
const overdueDays = getDaysOld(a.$createdAt || a.erstelltAm) - 7;
if (statusFilter === 'overdue_2' && !(overdueDays >= 1 && overdueDays <= 2)) return false;
if (statusFilter === 'overdue_4' && !(overdueDays >= 3 && overdueDays <= 4)) return false;
if (statusFilter === 'overdue_6' && !(overdueDays >= 5)) return false;
} else if (statusFilter === 'overdue') {
if (!isOverdue(a)) return false;
} else if (statusFilter && STATUS_FILTER_MAP[statusFilter]) {
if (a.status !== STATUS_FILTER_MAP[statusFilter]) return false;
@@ -184,14 +193,22 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
printWindow.document.close();
}
async function handleStatusChange(id) {
async function handleStatusChange(id, newStatus) {
try {
await onChangeStatus(id);
await onChangeStatus(id, newStatus);
} catch {
showToast('Statusänderung fehlgeschlagen!', '#C62828');
}
}
async function handleBearbeitungsStatus(id, substatus) {
try {
await onBearbeitungsStatus(id, substatus);
} catch {
showToast('Bearbeitungsstatus-Ä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 }));
@@ -244,7 +261,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
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;
const statusBtnCfg = STATUS_BUTTON_CONFIG[a.status] || STATUS_BUTTON_CONFIG.offen;
const isInBearbeitung = a.status === 'in_bearbeitung';
const rowClassName = overdue
? `bg-amber-50/50 dark:bg-amber-950/20 [&>td:first-child]:!border-l-2 [&>td:first-child]:!border-l-amber-500`
@@ -286,35 +303,78 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
</TableCell>
<TableCell className="w-[200px] min-w-[200px] p-0 align-top">
<div className="grid grid-cols-2 grid-rows-2 gap-0 h-full min-h-[80px] w-full">
<Button
variant={statusBtnCfg.variant}
size="sm"
className={`h-full w-full rounded-none flex items-center justify-center text-xs font-medium ${statusBtnCfg.className || ''}`}
onClick={() => handleStatusChange(a.$id)}
>
{NEXT_LABEL[a.status]}
</Button>
<Button
variant="default"
size="sm"
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
onClick={() => navigate(`/asset/${a.$id}`)}
>
Bearbeiten
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasInfoContent(a.kommentar)}
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
onClick={() => hasInfoContent(a.kommentar) && setCommentAsset(a)}
>
Info
</Button>
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground bg-muted/30 px-1 truncate" title={a.zustaendig || ''}>
<div
className="grid grid-cols-2 grid-rows-2 gap-0 h-full min-h-[80px] w-full"
ref={(el) => {
// #region agent log
if (el && index < 3) { const kids = el.children.length; const tags = Array.from(el.children).map(c => `${c.tagName}.${c.className?.split?.(' ')?.[0] || ''}(${c.children.length}kids)`); fetch('http://127.0.0.1:7886/ingest/990166f5-529c-4789-bcc2-9ebbe976f059',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'3a14a8'},body:JSON.stringify({sessionId:'3a14a8',location:'DefektTable.jsx:grid-ref',message:'Grid wrapper children',data:{assetId:a.$id,status:a.status,isInBearbeitung,childCount:kids,childTags:tags},timestamp:Date.now()})}).catch(()=>{}); }
// #endregion
}}
>
<div className={`h-full ${isInBearbeitung ? '' : 'col-span-2'} [&_button]:!w-full [&_button]:!h-full`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="rounded-none flex items-center justify-center gap-1 text-xs font-medium"
>
{STATUS_LABEL[a.status]}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{STATUS_DROPDOWN_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
disabled={opt.value === a.status}
onClick={() => handleStatusChange(a.$id, opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{isInBearbeitung && (
<div className="h-full [&_button]:!w-full [&_button]:!h-full">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="rounded-none flex items-center justify-center gap-1 text-xs font-medium"
>
Status
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{BEARBEITUNGS_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => handleBearbeitungsStatus(a.$id, opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="h-full flex items-center justify-center text-xs font-medium text-muted-foreground bg-muted/30 px-1 truncate" title={a.zustaendig || ''}>
{a.zustaendig || ''}
</div>
<div className="h-full">
<Button
variant="outline"
size="sm"
className="!w-full !h-full rounded-none flex items-center justify-center text-xs font-medium"
onClick={() => navigate(`/asset/${a.$id}`)}
>
Info
</Button>
</div>
</div>
</TableCell>
</TableRow>
@@ -331,9 +391,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
</div>
)}
{commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)}
</div>
);
}

View File

@@ -13,7 +13,7 @@ import { useAuth } from '../context/AuthContext';
export default function DefektTrackApp() {
const { user, userMeta } = useAuth();
const locationId = userMeta?.locationId || '';
const { assets, addAsset, changeStatus } = useAssets(locationId);
const { assets, addAsset, changeStatus, setBearbeitungsStatus, updateAsset } = useAssets(locationId);
const { addLog } = useAuditLog();
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
const { colleagues } = useColleagues(locationId);
@@ -39,13 +39,12 @@ export default function DefektTrackApp() {
return doc;
}, [addAsset, addLog, user, userName]);
const handleStatusChange = useCallback(async (id) => {
const handleStatusChange = useCallback(async (id, newStatus) => {
const asset = assets.find((a) => a.$id === id);
const oldStatus = asset?.status || '?';
await changeStatus(id);
if (oldStatus === newStatus) return;
await changeStatus(id, newStatus);
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',
@@ -55,6 +54,24 @@ export default function DefektTrackApp() {
});
}, [assets, changeStatus, addLog, user, userName]);
const BEARB_LABELS = {
portalpruefung: 'Portalprüfung durchführen',
gutschreiben_entsorgen: 'Direkt gutschreiben & entsorgen',
zurueck_hersteller: 'Zurück an Hersteller senden',
defekt_ankunft: 'Defekt bei Ankunft melden',
};
const handleBearbeitungsStatus = useCallback(async (id, substatus) => {
await setBearbeitungsStatus(id, substatus);
await addLog({
assetId: id,
action: 'bearbeitungsstatus',
details: `Bearbeitungsstatus: ${BEARB_LABELS[substatus] || substatus}`,
userId: user.$id,
userName,
});
}, [setBearbeitungsStatus, addLog, user, userName]);
return (
<div className="flex h-screen flex-col overflow-hidden">
<Header assets={assets} showToast={showToast} />
@@ -84,6 +101,7 @@ export default function DefektTrackApp() {
<DefektTable
assets={assets}
onChangeStatus={handleStatusChange}
onBearbeitungsStatus={handleBearbeitungsStatus}
showToast={showToast}
lagerstandorte={lagerstandorte}
statusFilter={statusFilter}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Sector, Label } from 'recharts';
import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
@@ -83,6 +84,7 @@ export default function FilialleiterDashboard() {
const [allAssets, setAllAssets] = useState([]);
const [allLocationsCount, setAllLocationsCount] = useState(1);
const [colleagues, setColleagues] = useState([]);
const [pieActiveIndex, setPieActiveIndex] = useState(-1);
const loadData = useCallback(async () => {
if (!locationId) return;
@@ -183,7 +185,7 @@ export default function FilialleiterDashboard() {
ueberfaellig: { label: 'Überfällig', color: '#ef4444' },
};
function computeStatusDistribution(assets) {
function computeStatusDistribution(assets) {
const offen = assets.filter((a) => a.status === 'offen').length;
const inBearbeitung = assets.filter((a) => a.status === 'in_bearbeitung').length;
const erledigt = assets.filter((a) => a.status === 'entsorgt').length;
@@ -201,15 +203,28 @@ export default function FilialleiterDashboard() {
];
}
const pieOwnData = useMemo(() => computeStatusDistribution(ownAssets), [ownAssets]);
const pieAvgData = useMemo(() => {
if (allLocationsCount <= 1) return computeStatusDistribution(allAssets);
return computeStatusDistribution(allAssets).map((d) => ({
...d,
value: Math.round(d.value / allLocationsCount),
}));
}, [allAssets, allLocationsCount]);
function computeDayActivity(assets, dayStart, dayEnd) {
const offen = countInRange(assets, dayStart, dayEnd);
const inBearbeitung = assets.filter((a) => {
if (a.status !== 'in_bearbeitung') return false;
const d = new Date(a.$updatedAt || a.$createdAt);
return d >= dayStart && d <= dayEnd;
}).length;
const erledigt = countErledigtInRange(assets, dayStart, dayEnd);
const sevenDaysBefore = new Date(dayStart);
sevenDaysBefore.setDate(sevenDaysBefore.getDate() - 7);
const ueberfaellig = assets.filter((a) => {
if (a.status === 'entsorgt') return false;
const created = new Date(a.$createdAt);
return created >= sevenDaysBefore && created < dayStart;
}).length;
return [
{ status: 'offen', value: offen, fill: 'var(--color-offen)' },
{ status: 'inBearbeitung', value: inBearbeitung, fill: 'var(--color-inBearbeitung)' },
{ status: 'erledigt', value: erledigt, fill: 'var(--color-erledigt)' },
{ status: 'ueberfaellig', value: ueberfaellig, fill: 'var(--color-ueberfaellig)' },
];
}
const emptyPieData = [
{ status: 'offen', value: 0, fill: 'var(--color-offen)' },
@@ -218,35 +233,39 @@ export default function FilialleiterDashboard() {
{ status: 'ueberfaellig', value: 1, fill: 'hsl(var(--muted))' },
];
function wrapPieResult(data) {
const total = data.reduce((s, d) => s + d.value, 0);
return { data: total > 0 ? data : emptyPieData, total };
}
const otherAssets = useMemo(() => {
const ownIds = new Set(ownAssets.map((a) => a.$id));
return allAssets.filter((a) => !ownIds.has(a.$id));
}, [allAssets, ownAssets]);
const otherLocationsCount = Math.max(allLocationsCount - 1, 1);
function applyAverage(data, count) {
if (count <= 1) return data;
return data.map((d) => ({ ...d, value: Math.round(d.value / count) }));
}
const pieOwnOverall = useMemo(() => computeStatusDistribution(ownAssets), [ownAssets]);
const pieAvgOverall = useMemo(() => applyAverage(computeStatusDistribution(otherAssets), otherLocationsCount), [otherAssets, otherLocationsCount]);
const pieOwnToday = useMemo(() => wrapPieResult(computeDayActivity(ownAssets, today, now)), [ownAssets]);
const pieAvgToday = useMemo(() => wrapPieResult(applyAverage(computeDayActivity(otherAssets, today, now), otherLocationsCount)), [otherAssets, otherLocationsCount]);
const pieOwnMonth = useMemo(() => {
const monthAssets = ownAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
const data = computeStatusDistribution(monthAssets);
const total = data.reduce((s, d) => s + d.value, 0);
// #region agent log
fetch('http://127.0.0.1:7886/ingest/990166f5-529c-4789-bcc2-9ebbe976f059',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'5a8700'},body:JSON.stringify({sessionId:'5a8700',location:'FilialleiterDashboard.jsx:pieOwnMonth',message:'pieOwnMonth computed',data:{monthAssetsCount:monthAssets.length,total,hasData:total>0},timestamp:Date.now(),runId:'post-fix',hypothesisId:'H1-fix'})}).catch(()=>{});
// #endregion
return { data: total > 0 ? data : emptyPieData, total };
return wrapPieResult(computeStatusDistribution(monthAssets));
}, [ownAssets, monthStart]);
const pieAvgMonth = useMemo(() => {
const monthAssets = allAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
let data;
if (allLocationsCount <= 1) {
data = computeStatusDistribution(monthAssets);
} else {
data = computeStatusDistribution(monthAssets).map((d) => ({
...d,
value: Math.round(d.value / allLocationsCount),
}));
}
const total = data.reduce((s, d) => s + d.value, 0);
// #region agent log
fetch('http://127.0.0.1:7886/ingest/990166f5-529c-4789-bcc2-9ebbe976f059',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'5a8700'},body:JSON.stringify({sessionId:'5a8700',location:'FilialleiterDashboard.jsx:pieAvgMonth',message:'pieAvgMonth computed',data:{monthAssetsCount:monthAssets.length,total,hasData:total>0},timestamp:Date.now(),runId:'post-fix',hypothesisId:'H1-fix'})}).catch(()=>{});
// #endregion
return { data: total > 0 ? data : emptyPieData, total };
}, [allAssets, allLocationsCount, monthStart]);
const monthAssets = otherAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
return wrapPieResult(applyAverage(computeStatusDistribution(monthAssets), otherLocationsCount));
}, [otherAssets, otherLocationsCount, monthStart]);
const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssets.length / allLocationsCount) : 0;
const ownTotal = ownAssets.length;
const employeeStats = useMemo(() => {
@@ -276,7 +295,15 @@ export default function FilialleiterDashboard() {
const dayTrend = trendArrow(todayCount, yesterdayCount);
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
const comparisonMax = Math.max(ownTotal, avgAllFilialen, 1);
const overdueAssets = useMemo(() => {
return ownAssets
.filter(isOverdue)
.map((a) => {
const days = getDaysOld(a.$createdAt);
return { ...a, daysOld: days, daysOverdue: days - 7 };
})
.sort((a, b) => b.daysOld - a.daysOld);
}, [ownAssets]);
return (
<>
@@ -287,6 +314,155 @@ export default function FilialleiterDashboard() {
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Aktuelle Gesamtübersicht</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 items-center gap-6 lg:grid-cols-[auto_1fr]">
<div className="flex items-center gap-6">
<ChartContainer config={pieChartConfig} className="aspect-square w-[280px] shrink-0">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
<Pie
data={pieOwnOverall}
dataKey="value"
nameKey="status"
innerRadius={70}
outerRadius={115}
strokeWidth={3}
activeIndex={pieActiveIndex >= 0 ? pieActiveIndex : undefined}
activeShape={({ outerRadius = 0, ...props }) => (
<g>
<Sector {...props} outerRadius={outerRadius + 8} />
<Sector {...props} innerRadius={outerRadius + 10} outerRadius={outerRadius + 14} />
</g>
)}
>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
const total = pieOwnOverall.reduce((s, d) => s + d.value, 0);
return (
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
<tspan className="fill-foreground text-3xl font-bold" x={viewBox.cx} y={viewBox.cy}>
{total}
</tspan>
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 22}>
Gesamt
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2.5 py-2">
{pieOwnOverall.map((entry, idx) => {
const total = pieOwnOverall.reduce((s, d) => s + d.value, 0);
const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
const colorMap = { offen: '#60a5fa', inBearbeitung: '#f59e0b', erledigt: '#22c55e', ueberfaellig: '#ef4444' };
return (
<div
key={entry.status}
className="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted/60"
onMouseEnter={() => setPieActiveIndex(idx)}
onMouseLeave={() => setPieActiveIndex(-1)}
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: colorMap[entry.status] }}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{pieChartConfig[entry.status]?.label}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tabular-nums">{entry.value}</span>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</div>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-all"
style={{ width: `${pct}%`, backgroundColor: colorMap[entry.status] }}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="flex flex-col">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">
Überfällige Einträge
{overdueAssets.length > 0 && (
<Badge variant="destructive" className="ml-2">{overdueAssets.length}</Badge>
)}
</p>
</div>
{overdueAssets.length === 0 ? (
<div className="flex items-center justify-center rounded-md border border-dashed py-8 text-sm text-muted-foreground">
Keine überfälligen Einträge
</div>
) : (
<div className="max-h-[280px] overflow-y-auto rounded-md border">
<Table className="text-xs">
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="w-16 px-2">ERL-Nr.</TableHead>
<TableHead className="px-2">Artikel</TableHead>
<TableHead className="w-20 px-2">Status</TableHead>
<TableHead className="w-20 px-2 text-right">Tage</TableHead>
<TableHead className="w-24 px-2">Zuständig</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overdueAssets.map((a) => (
<TableRow
key={a.$id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigate(`/asset/${a.$id}`)}
>
<TableCell className={`font-semibold px-2 py-1.5 ${
a.prio === 'kritisch' ? '!bg-red-600 text-red-50' :
a.prio === 'hoch' ? '!bg-orange-500 text-orange-50' :
a.prio === 'mittel' ? '!bg-yellow-400 text-yellow-950' :
'!bg-green-500 text-green-50'
}`}>
{a.erlNummer || ''}
</TableCell>
<TableCell className="px-2 py-1.5">
<div className="font-medium truncate max-w-[160px]">{a.bezeichnung || a.artikelNr || ''}</div>
</TableCell>
<TableCell className="px-2 py-1.5">
<Badge
variant={a.status === 'offen' ? 'destructive' : 'default'}
className={`text-[10px] px-1.5 py-0 ${a.status === 'in_bearbeitung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : ''}`}
>
{a.status === 'offen' ? 'Offen' : 'In Bearb.'}
</Badge>
</TableCell>
<TableCell className="px-2 py-1.5 text-right tabular-nums">
<span className="font-semibold text-red-600 dark:text-red-400">{a.daysOverdue}d</span>
</TableCell>
<TableCell className="px-2 py-1.5 truncate max-w-[90px]">{a.zustaendig || ''}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardContent className="pt-4">
<div className="grid grid-cols-1 items-center gap-4 lg:grid-cols-[1fr_2fr_1fr]">
@@ -296,7 +472,7 @@ export default function FilialleiterDashboard() {
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
<Pie
data={pieOwnData}
data={pieOwnToday.data}
dataKey="value"
nameKey="status"
innerRadius={50}
@@ -310,14 +486,13 @@ export default function FilialleiterDashboard() {
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
const total = pieOwnData.reduce((s, d) => s + d.value, 0);
return (
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
{total}
{pieOwnToday.total}
</tspan>
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
Gesamt
Heute
</tspan>
</text>
);
@@ -359,12 +534,12 @@ export default function FilialleiterDashboard() {
</div>
<div className="flex flex-col items-center justify-center">
<p className="mb-2 text-sm font-medium text-muted-foreground"> Alle Filialen</p>
<p className="mb-2 text-sm font-medium text-muted-foreground"> Andere Filialen</p>
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
<Pie
data={pieAvgData}
data={pieAvgToday.data}
dataKey="value"
nameKey="status"
innerRadius={50}
@@ -378,14 +553,13 @@ export default function FilialleiterDashboard() {
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
const total = pieAvgData.reduce((s, d) => s + d.value, 0);
return (
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
{total}
{pieAvgToday.total}
</tspan>
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
Gesamt
Heute
</tspan>
</text>
);
@@ -469,7 +643,7 @@ export default function FilialleiterDashboard() {
</div>
<div className="flex flex-col items-center justify-center">
<p className="mb-2 text-sm font-medium text-muted-foreground"> Alle Filialen</p>
<p className="mb-2 text-sm font-medium text-muted-foreground"> Andere Filialen</p>
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
@@ -509,26 +683,6 @@ export default function FilialleiterDashboard() {
</CardContent>
</Card>
<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>

View File

@@ -1,11 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
import { useCallback, useEffect, useRef, useState } from 'react';
import { client, storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
import { isImageFilename } from '@/lib/kommentarAnhaenge';
import { FileText, Download } from 'lucide-react';
import { buttonVariants } from '@/components/ui/button';
import { FileText, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/** Bild mit Appwrite-Session: <img> sendet keine Cookies zu /view — daher fetch → Blob-URL. */
function authFetch(url, opts = {}) {
const headers = { ...client.headers };
try {
const fallback = window.localStorage.getItem('cookieFallback');
if (fallback) headers['X-Fallback-Cookies'] = fallback;
} catch { /* noop */ }
return fetch(url, {
...opts,
credentials: 'include',
headers: { ...headers, ...(opts.headers || {}) },
});
}
function AuthenticatedImagePreview({ viewUrl, alt, className }) {
const [src, setSrc] = useState(null);
const [status, setStatus] = useState('loading');
@@ -20,7 +32,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
blobRef.current = null;
}
fetch(viewUrl, { credentials: 'include', mode: 'cors', signal: ac.signal })
authFetch(viewUrl, { signal: ac.signal })
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.blob();
@@ -34,6 +46,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
})
.catch((e) => {
if (e.name === 'AbortError') return;
console.warn('Image preview failed:', e);
setStatus('error');
});
@@ -49,7 +62,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
if (status === 'error') {
return (
<p className="border-t px-3 py-3 text-center text-xs text-muted-foreground">
Vorschau nicht möglich. Bitte Herunterladen nutzen.
Vorschau nicht möglich.
</p>
);
}
@@ -67,6 +80,38 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
);
}
function DownloadButton({ downloadUrl, filename }) {
const [busy, setBusy] = useState(false);
const handleDownload = useCallback(async () => {
setBusy(true);
try {
const res = await authFetch(downloadUrl);
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
console.error('Download failed:', e);
} finally {
setBusy(false);
}
}, [downloadUrl, filename]);
return (
<Button variant="outline" size="sm" className="shrink-0 gap-1.5" onClick={handleDownload} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : <Download className="size-3.5" aria-hidden />}
Herunterladen
</Button>
);
}
export default function KommentarAnhaengeList({ attachments }) {
if (!attachments?.length) return null;
@@ -86,18 +131,7 @@ export default function KommentarAnhaengeList({ attachments }) {
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" title={a.name}>
{a.name}
</span>
<a
href={downloadUrl}
target="_blank"
rel="noreferrer"
className={cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'shrink-0 gap-1.5 no-underline'
)}
>
<Download className="size-3.5" aria-hidden />
Herunterladen
</a>
<DownloadButton downloadUrl={downloadUrl} filename={a.name} />
</div>
{showImage && <AuthenticatedImagePreview viewUrl={viewUrl} alt={a.name} />}
</li>

View File

@@ -3,7 +3,10 @@ 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' };
function hasBearbeitungsStatusField(asset) {
return asset && 'bearbeitungsStatus' in asset;
}
export function useAssets(locationId = '') {
const [assets, setAssets] = useState([]);
@@ -43,7 +46,7 @@ export function useAssets(locationId = '') {
useEffect(() => { loadAssets(); }, [loadAssets]);
const addAsset = useCallback(async (data) => {
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
const payload = {
erlNummer: data.erlNummer,
seriennummer: data.seriennummer,
artikelNr: data.artikelNr || '',
@@ -56,19 +59,37 @@ export function useAssets(locationId = '') {
kommentar: data.kommentar || '',
createdBy: data.createdBy || '',
lastEditedBy: data.lastEditedBy || '',
});
};
const existingAsset = assets[0];
if (!existingAsset || hasBearbeitungsStatusField(existingAsset)) {
payload.bearbeitungsStatus = '';
}
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), payload);
setAssets((prev) => [doc, ...prev]);
return doc;
}, []);
}, [assets]);
const changeStatus = useCallback(async (id) => {
const changeStatus = useCallback(async (id, newStatus) => {
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 });
const updates = { status: newStatus };
if (newStatus !== 'in_bearbeitung' && hasBearbeitungsStatusField(asset)) {
updates.bearbeitungsStatus = '';
}
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, updates);
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
}, [assets]);
const setBearbeitungsStatus = useCallback(async (id, substatus) => {
const asset = assets.find((a) => a.$id === id);
if (!hasBearbeitungsStatusField(asset)) {
console.warn('bearbeitungsStatus Feld existiert noch nicht in der DB. Bitte Migrations-Skript ausführen: node scripts/migrate-bearbeitungsstatus.js');
return asset;
}
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, { bearbeitungsStatus: substatus });
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
return updated;
}, [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));
@@ -89,7 +110,7 @@ export function useAssets(locationId = '') {
}
}, []);
return { assets, loading, addAsset, changeStatus, updateAsset, deleteAsset, getAsset, reload: loadAssets };
return { assets, loading, addAsset, changeStatus, setBearbeitungsStatus, updateAsset, deleteAsset, getAsset, reload: loadAssets };
}
export function getDaysOld(ts) {