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, 'zustaendig', 128, true);
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'status', 32, 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, '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, 'kommentar', 8192, false, '');
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'createdBy', 128, false, '');
|
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'createdBy', 128, false, '');
|
||||||
await databases.createStringAttribute(DATABASE_ID, COLLECTION_ID, 'lastEditedBy', 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() {
|
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_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
|
||||||
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
|
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
|
||||||
const STATUS_OPTIONS = ['offen', 'in_bearbeitung', 'entsorgt'];
|
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) {
|
function formatTimestamp(ts) {
|
||||||
if (!ts) return '–';
|
if (!ts) return '–';
|
||||||
@@ -97,6 +105,7 @@ export default function AssetDetail() {
|
|||||||
zustaendig: doc.zustaendig || '',
|
zustaendig: doc.zustaendig || '',
|
||||||
status: doc.status || 'offen',
|
status: doc.status || 'offen',
|
||||||
prio: doc.prio || 'mittel',
|
prio: doc.prio || 'mittel',
|
||||||
|
bearbeitungsStatus: doc.bearbeitungsStatus || '',
|
||||||
kommentar: doc.kommentar || '',
|
kommentar: doc.kommentar || '',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -125,6 +134,7 @@ export default function AssetDetail() {
|
|||||||
{ key: 'zustaendig', label: 'Zuständig' },
|
{ key: 'zustaendig', label: 'Zuständig' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'status', label: 'Status' },
|
||||||
{ key: 'prio', label: 'Priorität' },
|
{ key: 'prio', label: 'Priorität' },
|
||||||
|
{ key: 'bearbeitungsStatus', label: 'Bearbeitungsstatus' },
|
||||||
{ key: 'kommentar', label: 'Kommentar' },
|
{ key: 'kommentar', label: 'Kommentar' },
|
||||||
];
|
];
|
||||||
const changes = [];
|
const changes = [];
|
||||||
@@ -134,6 +144,8 @@ export default function AssetDetail() {
|
|||||||
if (oldVal !== newVal) {
|
if (oldVal !== newVal) {
|
||||||
if (f.key === 'status') {
|
if (f.key === 'status') {
|
||||||
changes.push(`${f.label}: ${STATUS_LABEL[oldVal] || oldVal} → ${STATUS_LABEL[newVal] || newVal}`);
|
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') {
|
} else if (f.key === 'prio') {
|
||||||
changes.push(`${f.label}: ${PRIO_LABELS[oldVal] || oldVal} → ${PRIO_LABELS[newVal] || newVal}`);
|
changes.push(`${f.label}: ${PRIO_LABELS[oldVal] || oldVal} → ${PRIO_LABELS[newVal] || newVal}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -201,6 +213,7 @@ export default function AssetDetail() {
|
|||||||
zustaendig: asset.zustaendig || '',
|
zustaendig: asset.zustaendig || '',
|
||||||
status: asset.status || 'offen',
|
status: asset.status || 'offen',
|
||||||
prio: asset.prio || 'mittel',
|
prio: asset.prio || 'mittel',
|
||||||
|
bearbeitungsStatus: asset.bearbeitungsStatus || '',
|
||||||
kommentar: asset.kommentar || '',
|
kommentar: asset.kommentar || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -369,6 +382,26 @@ export default function AssetDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
<Label>Kommentar</Label>
|
<Label>Kommentar</Label>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function CommentPopup({ artikel, onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-lg max-h-[85vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
|
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
@@ -23,7 +23,7 @@ export default function CommentPopup({ artikel, onClose }) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 overflow-y-auto min-h-0 flex-1">
|
||||||
{subject && (
|
{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">
|
<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}
|
{subject}
|
||||||
|
|||||||
@@ -1,49 +1,67 @@
|
|||||||
import { isOverdue } from '../hooks/useAssets';
|
import { isOverdue, getDaysOld } from '../hooks/useAssets';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
const STAT_CARDS = [
|
const STATUS_CARDS = [
|
||||||
{ key: 'offen', color: '#DC2626', label: 'Offen' },
|
{ key: 'offen', color: '#DC2626', label: 'Offen' },
|
||||||
{ key: 'bearbeitung', color: '#F59E0B', label: 'In Bearbeitung' },
|
{ key: 'bearbeitung', color: '#F59E0B', label: 'In Bearbeitung' },
|
||||||
{ key: 'entsorgt', color: '#6B7280', label: 'Entsorgt' },
|
{ 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 }) {
|
export default function Dashboard({ assets, statusFilter, onStatusFilterChange }) {
|
||||||
|
const overdueAssets = assets.filter(isOverdue);
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
offen: assets.filter((a) => a.status === 'offen').length,
|
offen: assets.filter((a) => a.status === 'offen').length,
|
||||||
bearbeitung: assets.filter((a) => a.status === 'in_bearbeitung').length,
|
bearbeitung: assets.filter((a) => a.status === 'in_bearbeitung').length,
|
||||||
entsorgt: assets.filter((a) => a.status === 'entsorgt').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) => {
|
const handleCardClick = (key) => {
|
||||||
onStatusFilterChange?.(statusFilter === key ? null : 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 (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
<div className="space-y-3">
|
||||||
{STAT_CARDS.map(({ key, color, label }) => {
|
<div className="grid grid-cols-3 gap-3">
|
||||||
const isSelected = statusFilter === key;
|
{STATUS_CARDS.map(renderCard)}
|
||||||
return (
|
</div>
|
||||||
<Card
|
<div className="grid grid-cols-3 gap-3">
|
||||||
key={key}
|
{OVERDUE_CARDS.map(renderCard)}
|
||||||
className="py-0 cursor-pointer transition-all duration-200 hover:opacity-90"
|
</div>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
||||||
import { hasInfoContent } from '@/lib/kommentarAnhaenge';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import CommentPopup from './CommentPopup';
|
|
||||||
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 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_ORDER = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
|
||||||
const PRIO_COLORS = {
|
|
||||||
kritisch: 'bg-red-600',
|
const STATUS_DROPDOWN_OPTIONS = [
|
||||||
hoch: 'bg-orange-500',
|
{ value: 'offen', label: 'Offen' },
|
||||||
mittel: 'bg-yellow-500',
|
{ value: 'in_bearbeitung', label: 'In Bearbeitung' },
|
||||||
niedrig: 'bg-green-500',
|
{ 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 */
|
/** Prioritätsfarbe füllt die komplette ERL-Nr.-Zelle als Hintergrund */
|
||||||
const PRIO_ERL_CELL = {
|
const PRIO_ERL_CELL = {
|
||||||
@@ -41,12 +51,6 @@ const STATUS_BADGE_CONFIG = {
|
|||||||
entsorgt: { variant: 'secondary' },
|
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) {
|
function resolveStandortName(asset, lagerstandorte) {
|
||||||
if (!asset.lagerstandortId) return '–';
|
if (!asset.lagerstandortId) return '–';
|
||||||
const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId);
|
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 =
|
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';
|
'[&>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 { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeFilter, setActiveFilter] = useState(null);
|
const [activeFilter, setActiveFilter] = useState(null);
|
||||||
const [commentAsset, setCommentAsset] = useState(null);
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
erlNummer: '',
|
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.seriennummer && !(a.seriennummer || '').toLowerCase().includes(filters.seriennummer.toLowerCase())) return false;
|
||||||
if (filters.defekt && !(a.defekt || '').toLowerCase().includes(filters.defekt.toLowerCase())) return false;
|
if (filters.defekt && !(a.defekt || '').toLowerCase().includes(filters.defekt.toLowerCase())) return false;
|
||||||
if (filters.standort && a.lagerstandortId !== filters.standort) return false;
|
if (filters.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;
|
if (!isOverdue(a)) return false;
|
||||||
} else if (statusFilter && STATUS_FILTER_MAP[statusFilter]) {
|
} else if (statusFilter && STATUS_FILTER_MAP[statusFilter]) {
|
||||||
if (a.status !== STATUS_FILTER_MAP[statusFilter]) return false;
|
if (a.status !== STATUS_FILTER_MAP[statusFilter]) return false;
|
||||||
@@ -184,14 +193,22 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatusChange(id) {
|
async function handleStatusChange(id, newStatus) {
|
||||||
try {
|
try {
|
||||||
await onChangeStatus(id);
|
await onChangeStatus(id, newStatus);
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Statusänderung fehlgeschlagen!', '#C62828');
|
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 sortLabel = SORT_OPTIONS.find((o) => o.value === filters.sortBy)?.label || '';
|
||||||
const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
|
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 overdue = isOverdue(a);
|
||||||
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
|
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
|
||||||
const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
|
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
|
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`
|
? `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>
|
||||||
|
|
||||||
<TableCell className="w-[200px] min-w-[200px] p-0 align-top">
|
<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">
|
<div
|
||||||
<Button
|
className="grid grid-cols-2 grid-rows-2 gap-0 h-full min-h-[80px] w-full"
|
||||||
variant={statusBtnCfg.variant}
|
ref={(el) => {
|
||||||
size="sm"
|
// #region agent log
|
||||||
className={`h-full w-full rounded-none flex items-center justify-center text-xs font-medium ${statusBtnCfg.className || ''}`}
|
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(()=>{}); }
|
||||||
onClick={() => handleStatusChange(a.$id)}
|
// #endregion
|
||||||
>
|
}}
|
||||||
{NEXT_LABEL[a.status]}
|
>
|
||||||
</Button>
|
<div className={`h-full ${isInBearbeitung ? '' : 'col-span-2'} [&_button]:!w-full [&_button]:!h-full`}>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="default"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
|
variant="outline"
|
||||||
onClick={() => navigate(`/asset/${a.$id}`)}
|
size="sm"
|
||||||
>
|
className="rounded-none flex items-center justify-center gap-1 text-xs font-medium"
|
||||||
Bearbeiten
|
>
|
||||||
</Button>
|
{STATUS_LABEL[a.status]}
|
||||||
<Button
|
<ChevronDown className="h-3 w-3" />
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
</DropdownMenuTrigger>
|
||||||
disabled={!hasInfoContent(a.kommentar)}
|
<DropdownMenuContent align="start">
|
||||||
className="h-full w-full rounded-none flex items-center justify-center text-xs font-medium"
|
{STATUS_DROPDOWN_OPTIONS.map((opt) => (
|
||||||
onClick={() => hasInfoContent(a.kommentar) && setCommentAsset(a)}
|
<DropdownMenuItem
|
||||||
>
|
key={opt.value}
|
||||||
Info
|
disabled={opt.value === a.status}
|
||||||
</Button>
|
onClick={() => handleStatusChange(a.$id, opt.value)}
|
||||||
<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 || ''}>
|
>
|
||||||
|
{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 || '–'}
|
{a.zustaendig || '–'}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -331,9 +391,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commentAsset && (
|
|
||||||
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
export default function DefektTrackApp() {
|
export default function DefektTrackApp() {
|
||||||
const { user, userMeta } = useAuth();
|
const { user, userMeta } = useAuth();
|
||||||
const locationId = userMeta?.locationId || '';
|
const locationId = userMeta?.locationId || '';
|
||||||
const { assets, addAsset, changeStatus } = useAssets(locationId);
|
const { assets, addAsset, changeStatus, setBearbeitungsStatus, updateAsset } = useAssets(locationId);
|
||||||
const { addLog } = useAuditLog();
|
const { addLog } = useAuditLog();
|
||||||
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
|
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
|
||||||
const { colleagues } = useColleagues(locationId);
|
const { colleagues } = useColleagues(locationId);
|
||||||
@@ -39,13 +39,12 @@ export default function DefektTrackApp() {
|
|||||||
return doc;
|
return doc;
|
||||||
}, [addAsset, addLog, user, userName]);
|
}, [addAsset, addLog, user, userName]);
|
||||||
|
|
||||||
const handleStatusChange = useCallback(async (id) => {
|
const handleStatusChange = useCallback(async (id, newStatus) => {
|
||||||
const asset = assets.find((a) => a.$id === id);
|
const asset = assets.find((a) => a.$id === id);
|
||||||
const oldStatus = asset?.status || '?';
|
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 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({
|
await addLog({
|
||||||
assetId: id,
|
assetId: id,
|
||||||
action: 'status_geaendert',
|
action: 'status_geaendert',
|
||||||
@@ -55,6 +54,24 @@ export default function DefektTrackApp() {
|
|||||||
});
|
});
|
||||||
}, [assets, changeStatus, addLog, user, userName]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden">
|
<div className="flex h-screen flex-col overflow-hidden">
|
||||||
<Header assets={assets} showToast={showToast} />
|
<Header assets={assets} showToast={showToast} />
|
||||||
@@ -84,6 +101,7 @@ export default function DefektTrackApp() {
|
|||||||
<DefektTable
|
<DefektTable
|
||||||
assets={assets}
|
assets={assets}
|
||||||
onChangeStatus={handleStatusChange}
|
onChangeStatus={handleStatusChange}
|
||||||
|
onBearbeitungsStatus={handleBearbeitungsStatus}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
lagerstandorte={lagerstandorte}
|
lagerstandorte={lagerstandorte}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Sector, Label } from 'recharts';
|
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 { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||||
import { Query } from 'appwrite';
|
import { Query } from 'appwrite';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
@@ -83,6 +84,7 @@ export default function FilialleiterDashboard() {
|
|||||||
const [allAssets, setAllAssets] = useState([]);
|
const [allAssets, setAllAssets] = useState([]);
|
||||||
const [allLocationsCount, setAllLocationsCount] = useState(1);
|
const [allLocationsCount, setAllLocationsCount] = useState(1);
|
||||||
const [colleagues, setColleagues] = useState([]);
|
const [colleagues, setColleagues] = useState([]);
|
||||||
|
const [pieActiveIndex, setPieActiveIndex] = useState(-1);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!locationId) return;
|
if (!locationId) return;
|
||||||
@@ -183,7 +185,7 @@ export default function FilialleiterDashboard() {
|
|||||||
ueberfaellig: { label: 'Überfällig', color: '#ef4444' },
|
ueberfaellig: { label: 'Überfällig', color: '#ef4444' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeStatusDistribution(assets) {
|
function computeStatusDistribution(assets) {
|
||||||
const offen = assets.filter((a) => a.status === 'offen').length;
|
const offen = assets.filter((a) => a.status === 'offen').length;
|
||||||
const inBearbeitung = assets.filter((a) => a.status === 'in_bearbeitung').length;
|
const inBearbeitung = assets.filter((a) => a.status === 'in_bearbeitung').length;
|
||||||
const erledigt = assets.filter((a) => a.status === 'entsorgt').length;
|
const erledigt = assets.filter((a) => a.status === 'entsorgt').length;
|
||||||
@@ -201,15 +203,28 @@ export default function FilialleiterDashboard() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieOwnData = useMemo(() => computeStatusDistribution(ownAssets), [ownAssets]);
|
function computeDayActivity(assets, dayStart, dayEnd) {
|
||||||
|
const offen = countInRange(assets, dayStart, dayEnd);
|
||||||
const pieAvgData = useMemo(() => {
|
const inBearbeitung = assets.filter((a) => {
|
||||||
if (allLocationsCount <= 1) return computeStatusDistribution(allAssets);
|
if (a.status !== 'in_bearbeitung') return false;
|
||||||
return computeStatusDistribution(allAssets).map((d) => ({
|
const d = new Date(a.$updatedAt || a.$createdAt);
|
||||||
...d,
|
return d >= dayStart && d <= dayEnd;
|
||||||
value: Math.round(d.value / allLocationsCount),
|
}).length;
|
||||||
}));
|
const erledigt = countErledigtInRange(assets, dayStart, dayEnd);
|
||||||
}, [allAssets, allLocationsCount]);
|
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 = [
|
const emptyPieData = [
|
||||||
{ status: 'offen', value: 0, fill: 'var(--color-offen)' },
|
{ status: 'offen', value: 0, fill: 'var(--color-offen)' },
|
||||||
@@ -218,35 +233,39 @@ export default function FilialleiterDashboard() {
|
|||||||
{ status: 'ueberfaellig', value: 1, fill: 'hsl(var(--muted))' },
|
{ 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 pieOwnMonth = useMemo(() => {
|
||||||
const monthAssets = ownAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
const monthAssets = ownAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
||||||
const data = computeStatusDistribution(monthAssets);
|
return wrapPieResult(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 };
|
|
||||||
}, [ownAssets, monthStart]);
|
}, [ownAssets, monthStart]);
|
||||||
|
|
||||||
const pieAvgMonth = useMemo(() => {
|
const pieAvgMonth = useMemo(() => {
|
||||||
const monthAssets = allAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
const monthAssets = otherAssets.filter((a) => new Date(a.$createdAt) >= monthStart);
|
||||||
let data;
|
return wrapPieResult(applyAverage(computeStatusDistribution(monthAssets), otherLocationsCount));
|
||||||
if (allLocationsCount <= 1) {
|
}, [otherAssets, otherLocationsCount, monthStart]);
|
||||||
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 avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssets.length / allLocationsCount) : 0;
|
|
||||||
const ownTotal = ownAssets.length;
|
const ownTotal = ownAssets.length;
|
||||||
|
|
||||||
const employeeStats = useMemo(() => {
|
const employeeStats = useMemo(() => {
|
||||||
@@ -276,7 +295,15 @@ export default function FilialleiterDashboard() {
|
|||||||
const dayTrend = trendArrow(todayCount, yesterdayCount);
|
const dayTrend = trendArrow(todayCount, yesterdayCount);
|
||||||
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -287,6 +314,155 @@ export default function FilialleiterDashboard() {
|
|||||||
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
||||||
</div>
|
</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">
|
<Card className="mb-6">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="grid grid-cols-1 items-center gap-4 lg:grid-cols-[1fr_2fr_1fr]">
|
<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>
|
<PieChart>
|
||||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
<Pie
|
<Pie
|
||||||
data={pieOwnData}
|
data={pieOwnToday.data}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="status"
|
nameKey="status"
|
||||||
innerRadius={50}
|
innerRadius={50}
|
||||||
@@ -310,14 +486,13 @@ export default function FilialleiterDashboard() {
|
|||||||
<Label
|
<Label
|
||||||
content={({ viewBox }) => {
|
content={({ viewBox }) => {
|
||||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
const total = pieOwnData.reduce((s, d) => s + d.value, 0);
|
|
||||||
return (
|
return (
|
||||||
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
<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}>
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
{total}
|
{pieOwnToday.total}
|
||||||
</tspan>
|
</tspan>
|
||||||
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
Gesamt
|
Heute
|
||||||
</tspan>
|
</tspan>
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
@@ -359,12 +534,12 @@ export default function FilialleiterDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
<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]">
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
<Pie
|
<Pie
|
||||||
data={pieAvgData}
|
data={pieAvgToday.data}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="status"
|
nameKey="status"
|
||||||
innerRadius={50}
|
innerRadius={50}
|
||||||
@@ -378,14 +553,13 @@ export default function FilialleiterDashboard() {
|
|||||||
<Label
|
<Label
|
||||||
content={({ viewBox }) => {
|
content={({ viewBox }) => {
|
||||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
const total = pieAvgData.reduce((s, d) => s + d.value, 0);
|
|
||||||
return (
|
return (
|
||||||
<text dominantBaseline="middle" textAnchor="middle" x={viewBox.cx} y={viewBox.cy}>
|
<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}>
|
<tspan className="fill-foreground text-2xl font-bold" x={viewBox.cx} y={viewBox.cy}>
|
||||||
{total}
|
{pieAvgToday.total}
|
||||||
</tspan>
|
</tspan>
|
||||||
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
<tspan className="fill-muted-foreground text-xs" x={viewBox.cx} y={(viewBox.cy || 0) + 20}>
|
||||||
⌀ Gesamt
|
⌀ Heute
|
||||||
</tspan>
|
</tspan>
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
@@ -469,7 +643,7 @@ export default function FilialleiterDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
<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]">
|
<ChartContainer config={pieChartConfig} className="mx-auto aspect-square w-full max-w-[220px]">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} cursor={false} />
|
||||||
@@ -509,26 +683,6 @@ export default function FilialleiterDashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Mitarbeiter-Performance</CardTitle>
|
<CardTitle>Mitarbeiter-Performance</CardTitle>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
|
import { client, storage, ATTACHMENTS_BUCKET_ID } from '@/lib/appwrite';
|
||||||
import { isImageFilename } from '@/lib/kommentarAnhaenge';
|
import { isImageFilename } from '@/lib/kommentarAnhaenge';
|
||||||
import { FileText, Download } from 'lucide-react';
|
import { FileText, Download, Loader2 } from 'lucide-react';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
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 }) {
|
function AuthenticatedImagePreview({ viewUrl, alt, className }) {
|
||||||
const [src, setSrc] = useState(null);
|
const [src, setSrc] = useState(null);
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
@@ -20,7 +32,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
|
|||||||
blobRef.current = null;
|
blobRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(viewUrl, { credentials: 'include', mode: 'cors', signal: ac.signal })
|
authFetch(viewUrl, { signal: ac.signal })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) throw new Error(String(r.status));
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
return r.blob();
|
return r.blob();
|
||||||
@@ -34,6 +46,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e.name === 'AbortError') return;
|
if (e.name === 'AbortError') return;
|
||||||
|
console.warn('Image preview failed:', e);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +62,7 @@ function AuthenticatedImagePreview({ viewUrl, alt, className }) {
|
|||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<p className="border-t px-3 py-3 text-center text-xs text-muted-foreground">
|
<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>
|
</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 }) {
|
export default function KommentarAnhaengeList({ attachments }) {
|
||||||
if (!attachments?.length) return null;
|
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}>
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" title={a.name}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<DownloadButton downloadUrl={downloadUrl} filename={a.name} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{showImage && <AuthenticatedImagePreview viewUrl={viewUrl} alt={a.name} />}
|
{showImage && <AuthenticatedImagePreview viewUrl={viewUrl} alt={a.name} />}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { databases, DATABASE_ID } from '../lib/appwrite';
|
|||||||
import { ID, Query } from 'appwrite';
|
import { ID, Query } from 'appwrite';
|
||||||
|
|
||||||
const COLLECTION = 'assets';
|
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 = '') {
|
export function useAssets(locationId = '') {
|
||||||
const [assets, setAssets] = useState([]);
|
const [assets, setAssets] = useState([]);
|
||||||
@@ -43,7 +46,7 @@ export function useAssets(locationId = '') {
|
|||||||
useEffect(() => { loadAssets(); }, [loadAssets]);
|
useEffect(() => { loadAssets(); }, [loadAssets]);
|
||||||
|
|
||||||
const addAsset = useCallback(async (data) => {
|
const addAsset = useCallback(async (data) => {
|
||||||
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
|
const payload = {
|
||||||
erlNummer: data.erlNummer,
|
erlNummer: data.erlNummer,
|
||||||
seriennummer: data.seriennummer,
|
seriennummer: data.seriennummer,
|
||||||
artikelNr: data.artikelNr || '',
|
artikelNr: data.artikelNr || '',
|
||||||
@@ -56,19 +59,37 @@ export function useAssets(locationId = '') {
|
|||||||
kommentar: data.kommentar || '',
|
kommentar: data.kommentar || '',
|
||||||
createdBy: data.createdBy || '',
|
createdBy: data.createdBy || '',
|
||||||
lastEditedBy: data.lastEditedBy || '',
|
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]);
|
setAssets((prev) => [doc, ...prev]);
|
||||||
return doc;
|
return doc;
|
||||||
}, []);
|
}, [assets]);
|
||||||
|
|
||||||
const changeStatus = useCallback(async (id) => {
|
const changeStatus = useCallback(async (id, newStatus) => {
|
||||||
const asset = assets.find((a) => a.$id === id);
|
const asset = assets.find((a) => a.$id === id);
|
||||||
if (!asset) return;
|
const updates = { status: newStatus };
|
||||||
const newStatus = NEXT_STATUS[asset.status];
|
if (newStatus !== 'in_bearbeitung' && hasBearbeitungsStatusField(asset)) {
|
||||||
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, { status: newStatus });
|
updates.bearbeitungsStatus = '';
|
||||||
|
}
|
||||||
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, updates);
|
||||||
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
|
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
|
||||||
}, [assets]);
|
}, [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 updateAsset = useCallback(async (id, data) => {
|
||||||
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, data);
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTION, id, data);
|
||||||
setAssets((prev) => prev.map((a) => a.$id === id ? updated : a));
|
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) {
|
export function getDaysOld(ts) {
|
||||||
|
|||||||
Reference in New Issue
Block a user