actionsleiste update
This commit is contained in:
617
docs/VERKAUF_UEBERSICHT.md
Normal file
617
docs/VERKAUF_UEBERSICHT.md
Normal 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 |
|
||||
| **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.
|
||||
56
scripts/migrate-bearbeitungsstatus.js
Normal file
56
scripts/migrate-bearbeitungsstatus.js
Normal 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();
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user