diff --git a/docs/VERKAUF_UEBERSICHT.md b/docs/VERKAUF_UEBERSICHT.md new file mode 100644 index 0000000..e825085 --- /dev/null +++ b/docs/VERKAUF_UEBERSICHT.md @@ -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 | +| **1–2 Tage überfällig** | Blau | Assets 1–2 Tage über der 7-Tage-Frist | Filtert auf diese Gruppe | +| **3–4 Tage überfällig** | Indigo | Assets 3–4 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. diff --git a/scripts/migrate-bearbeitungsstatus.js b/scripts/migrate-bearbeitungsstatus.js new file mode 100644 index 0000000..0d5f546 --- /dev/null +++ b/scripts/migrate-bearbeitungsstatus.js @@ -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(); diff --git a/scripts/setup-appwrite.js b/scripts/setup-appwrite.js index f525eb1..49829a0 100644 --- a/scripts/setup-appwrite.js +++ b/scripts/setup-appwrite.js @@ -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() { diff --git a/src/components/AssetDetail.jsx b/src/components/AssetDetail.jsx index f2dda16..b81efdc 100644 --- a/src/components/AssetDetail.jsx +++ b/src/components/AssetDetail.jsx @@ -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() { )} + {(form.status === 'in_bearbeitung' || asset.bearbeitungsStatus) && ( +
+ + {editing ? ( + + ) : ( +

{BEARB_STATUS_LABELS[asset.bearbeitungsStatus || ''] || '–'}

+ )} +
+ )} +
{editing ? ( diff --git a/src/components/CommentPopup.jsx b/src/components/CommentPopup.jsx index 6de9356..af67241 100644 --- a/src/components/CommentPopup.jsx +++ b/src/components/CommentPopup.jsx @@ -15,7 +15,7 @@ export default function CommentPopup({ artikel, onClose }) { return ( - + Kommentar zu {artikel.erlNummer} @@ -23,7 +23,7 @@ export default function CommentPopup({ artikel, onClose }) { -
+
{subject && (
{subject} diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index ffecc0b..5bf06c7 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -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: '1–2 Tage überfällig' }, + { key: 'overdue_4', color: '#6366F1', label: '3–4 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 ( + handleCardClick(key)} + > + +
{counts[key]}
+

{label}

+
+
+ ); + }; + return ( -
- {STAT_CARDS.map(({ key, color, label }) => { - const isSelected = statusFilter === key; - return ( - handleCardClick(key)} - > - -
{counts[key]}
-

{label}

-
-
- ); - })} +
+
+ {STATUS_CARDS.map(renderCard)} +
+
+ {OVERDUE_CARDS.map(renderCard)} +
); } diff --git a/src/components/DefektTable.jsx b/src/components/DefektTable.jsx index 73e803e..263a92f 100644 --- a/src/components/DefektTable.jsx +++ b/src/components/DefektTable.jsx @@ -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 -
- - - -
+
{ + // #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 + }} + > +
+ + + + + + {STATUS_DROPDOWN_OPTIONS.map((opt) => ( + handleStatusChange(a.$id, opt.value)} + > + {opt.label} + + ))} + + +
+ {isInBearbeitung && ( +
+ + + + + + {BEARBEITUNGS_OPTIONS.map((opt) => ( + handleBearbeitungsStatus(a.$id, opt.value)} + > + {opt.label} + + ))} + + +
+ )} +
{a.zustaendig || '–'}
+
+ +
@@ -331,9 +391,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
)} - {commentAsset && ( - setCommentAsset(null)} /> - )}
); } diff --git a/src/components/DefektTrackApp.jsx b/src/components/DefektTrackApp.jsx index 6d57d20..ed211cb 100644 --- a/src/components/DefektTrackApp.jsx +++ b/src/components/DefektTrackApp.jsx @@ -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 (
@@ -84,6 +101,7 @@ export default function DefektTrackApp() { { 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() {

Tägliche und monatliche Übersicht deiner Filiale

+ + + Aktuelle Gesamtübersicht + + +
+
+ + + } cursor={false} /> + = 0 ? pieActiveIndex : undefined} + activeShape={({ outerRadius = 0, ...props }) => ( + + + + + )} + > + + + + +
+ {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 ( +
setPieActiveIndex(idx)} + onMouseLeave={() => setPieActiveIndex(-1)} + > + +
+ + {pieChartConfig[entry.status]?.label} + +
+ {entry.value} + {pct}% +
+
+
+
+
+
+ ); + })} +
+
+ +
+
+

+ Überfällige Einträge + {overdueAssets.length > 0 && ( + {overdueAssets.length} + )} +

+
+ {overdueAssets.length === 0 ? ( +
+ Keine überfälligen Einträge +
+ ) : ( +
+ + + + ERL-Nr. + Artikel + Status + Tage + Zuständig + + + + {overdueAssets.map((a) => ( + navigate(`/asset/${a.$id}`)} + > + + {a.erlNummer || '–'} + + +
{a.bezeichnung || a.artikelNr || '–'}
+
+ + + {a.status === 'offen' ? 'Offen' : 'In Bearb.'} + + + + {a.daysOverdue}d + + {a.zustaendig || '–'} +
+ ))} +
+
+
+ )} +
+
+ + +
@@ -296,7 +472,7 @@ export default function FilialleiterDashboard() { } cursor={false} /> { if (viewBox && 'cx' in viewBox && 'cy' in viewBox) { - const total = pieOwnData.reduce((s, d) => s + d.value, 0); return ( - {total} + {pieOwnToday.total} - Gesamt + Heute ); @@ -359,12 +534,12 @@ export default function FilialleiterDashboard() {
-

⌀ Alle Filialen

+

⌀ Andere Filialen

} cursor={false} /> { if (viewBox && 'cx' in viewBox && 'cy' in viewBox) { - const total = pieAvgData.reduce((s, d) => s + d.value, 0); return ( - {total} + {pieAvgToday.total} - ⌀ Gesamt + ⌀ Heute ); @@ -469,7 +643,7 @@ export default function FilialleiterDashboard() {
-

⌀ Alle Filialen

+

⌀ Andere Filialen

} cursor={false} /> @@ -509,26 +683,6 @@ export default function FilialleiterDashboard() { - - - Filialvergleich - - -
-
- Meine Filiale - - {ownTotal} -
-
- ⌀ Durchschnitt - - {avgAllFilialen} -
-
-
-
- Mitarbeiter-Performance diff --git a/src/components/KommentarAnhaengeList.jsx b/src/components/KommentarAnhaengeList.jsx index a2f12be..83db094 100644 --- a/src/components/KommentarAnhaengeList.jsx +++ b/src/components/KommentarAnhaengeList.jsx @@ -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: 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 (

- Vorschau nicht möglich. Bitte „Herunterladen“ nutzen. + Vorschau nicht möglich.

); } @@ -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 ( + + ); +} + export default function KommentarAnhaengeList({ attachments }) { if (!attachments?.length) return null; @@ -86,18 +131,7 @@ export default function KommentarAnhaengeList({ attachments }) { {a.name} - - - Herunterladen - +
{showImage && } diff --git a/src/hooks/useAssets.js b/src/hooks/useAssets.js index 4bccaaa..f79d116 100644 --- a/src/hooks/useAssets.js +++ b/src/hooks/useAssets.js @@ -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) {