Compare commits

158 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 4c3f67eadc Merge origin/test in feature/terminplaner-tp2 (Projekte: Anlegen, Archiv, Vorlage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:12:04 +00:00
ClaudeandClaude Opus 5.5 878a530bd7 Terminplaner: Warnung im Dialog, wenn K16 mit den Unterlagen nicht rausging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:44 +00:00
ClaudeandClaude Sonnet 5.5 72eedeb59c Terminplaner: Abschalten-Dialog nennt Löschung nach 30 Tagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:36:12 +00:00
ClaudeandClaude Sonnet 5.5 89f5bd4bc6 Rechtliches: Schlüssel höchstens 36 Zeichen
Der Test der Karten-Schlüssel prüft jetzt die Appwrite-Grenze für eigene
Dokument-IDs (36 Zeichen) statt 64.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:45:27 +00:00
ClaudeandClaude Sonnet 5.5 225e2f1b74 Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners
Das Setup-Skript legt drei weitere Karten an (sort 7 bis 9, liveUrl = Alias-Seiten
ohne Fassungsdatum). Es legt nur Karten an (idempotent); Ausfuehrung mit API-Key
und Upload der Versionen macht der Inhaber.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:08:42 +00:00
ClaudeandClaude Sonnet 5.5 45e5dc30dc Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS
Im Dialog "Terminplaner ohne Zahlung aktivieren?" erscheinen, wenn das Portal
termine.textformNoetig meldet, der Haken (Leistungsbeschreibung und AVV in
der Fassung, Ausschluss Gesundheitsbereich), das Datum der Zustimmung und der
Weg (Chat | E-Mail) sowie die Textvorlage fuer den Chat mit Kopierknopf
(versionierte Adressen aus dem Portal). "Ja" bleibt gesperrt, bis alles
stimmt (canSubmitTextform mit minDate = Fassung); der Rumpf traegt
textformDate und textformChannel. News und Abschalten bleiben unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:07:37 +00:00
ClaudeandClaude Sonnet 5.5 857e001911 WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:08:04 +00:00
ClaudeandClaude Sonnet 5.5 c0015544c4 WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:54:24 +00:00
ClaudeandClaude Opus 5.5 603d08c9c2 Projekte: zur Vorlage gemachtes Projekt verschwindet nicht mehr
Vorlagen stehen absichtlich nur in der Ansicht "Vorlagen" (Admins). Nach
dem Speichern als Vorlage fiel das Projekt deshalb kommentarlos aus
"Aktive Projekte" (SHK-vorlage, 04.10.2026).

- nach dem Bearbeiten in die Ansicht wechseln, in der das Projekt jetzt
  steht, mit Hinweis ("steht jetzt unter Vorlagen")
- Suche zeigt Treffer in ausgeblendeten Ansichten (Vorlagen, Archiv) mit
  Klick zum Wechseln
- Hilfen viewShowing / matchesSearch / hiddenMatches in lib/projectList.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:01:38 +00:00
ClaudeandClaude Sonnet 5.5 6f0371283e Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:44:23 +00:00
ClaudeandClaude Opus 5.5 c262268015 Projekte: neue Gitea-Repos beim Oeffnen der Projekte-Seite abholen; Review-Kleinigkeiten
- Projekte-Seite holt neue/archivierte Repos selbst ab (vorher bis zu 10 Minuten unsichtbar)
- Import-Drossel: nach einem Fehler sofort neuer Versuch
- Zuordnen-Assistent nennt den Status einer vorhandenen Preview
- Ticket "Neues Projekt": ungueltige Subdomain wird abgewiesen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:33:40 +00:00
ClaudeandClaude Opus 5.5 f4c8b235b9 Projekte im Ticketsystem: Anlegen, Archiv und Anzeige repariert
- Neues Projekt (Projekte-Seite): Repo-Name blieb nach dem ersten
  Buchstaben der Subdomain stehen ("m" statt "musterfirma")
- Ticket "Projekt waehlen": archivierte Repos nach Flag ausblenden, nicht
  nach status (reaktivierte fehlten, archivierte wurden angeboten)
- Projektliste: "Aktive Projekte" + neue Ansicht "Alle inkl. Archiv";
  Liste wird auch nach einem Anlege-Fehler neu geladen
- Kundenauswahl: "Kein Kunde" entfernt Kunde und Ticket wirklich (mit
  Rueckfrage); beim Kundenwechsel bleibt kein fremdes Ticket haengen
- Zuordnen-Assistent ueberschreibt eine vorhandene Website nicht mehr
- Neues Projekt am Ticket bekommt die Art Website
- Umlaute in Subdomains wie im Portal (mueller statt m-ller)
- Preview-Block nur mit gespeicherter Subdomain; archivierte Projekte
  erst reaktivieren, bevor sie bearbeitet werden
- Repo-Import beim Aufklappen hoechstens einmal pro Minute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:23:01 +00:00
ClaudeandClaude Opus 5.5 78bc99a73b Login nur fuer Mitarbeiter (Label), Kunden-Konten werden abgewiesen
Kunden haben Konten im selben Appwrite-Projekt und konnten sich mit ihrem
Portal-Passwort im Ticketsystem anmelden; dabei entstand sogar automatisch ein
employees-Eintrag.

- lib/staffAccess.js: Zugang nur mit Label "mitarbeiter" oder "admin"
- AuthContext: Konto ohne Label -> Sitzung sofort beenden, Hinweis aufs
  Kundenportal (Login, Neuladen, nach dem 2FA-Code); employees-Eintrag nur
  noch fuer Mitarbeiter
- setup-legal.mjs: Rechte nur fuer die Labels (ein erneuter Lauf oeffnet
  nichts mehr fuer "users")

Die eigentliche Sperre sind die Appwrite-Rechte (Kundenportal
scripts/mitarbeiter-zugang.mjs --rechte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:54:16 +00:00
ClaudeandClaude Sonnet 5.5 65e9931f2c Terminplaner: Abschluss – Site-Key des Terminplaners im Funktionen-Reiter mit Kopierknopf
Die Zeile Terminplaner zeigt den siteKey aus der Antwort des Portals in kleiner Schrift mit fester
Breite und einem Kopierknopf (copyText aus CopyableCredential, mit Ersatzweg ohne Zwischenablage).
Damit braucht der Einbau (Schritt L4 des Pilots) keinen Datenbankzugriff mehr.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:49:23 +00:00
ClaudeandClaude Sonnet 5.5 426a5d6654 Funktionen-Reiter: News und Terminplaner je Projekt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:57:03 +00:00
ClaudeandClaude Opus 5.5 36eb55b81b Sidebar: Projekt-Admin entfernt (Admin-Panel abgeschaltet)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:55:04 +00:00
ClaudeandClaude Opus 5.5 feda436936 Listen der Admin-Bereiche stapeln nach verfuegbarer Breite (Tablet quer, halbes Fenster)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:16:33 +00:00
ClaudeandClaude Opus 5.5 281c49d406 Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:05:12 +00:00
ClaudeandClaude Opus 5.5 109aef6805 Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:02:24 +00:00
ClaudeandClaude Opus 5.5 cef0d5465e Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:58:37 +00:00
ClaudeandClaude Opus 5.5 91d526e909 Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:55:48 +00:00
ClaudeandClaude Opus 5.5 813c6a7e18 Chat: Korrekturen aus dem Abschlussreview
- Gespraech startet nicht neu, wenn der Kundenname nachlaedt (Entwurf bleibt, kein verwaister Timer)
- Neue Nachricht an Kunde: erst die eindeutige Beschriftung Name · E-Mail · Kd.-Nr. oeffnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:46:51 +00:00
ClaudeandClaude Opus 5.5 eb0b889203 Chat im Ticketsystem: Messenger unter Kunden, Reiter Chat auf der Kundenseite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:35:59 +00:00
ClaudeandClaude Opus 5.5 005875d970 Sidebar: Chat als Unterpunkt von Kunden mit Zahl der ungelesenen Nachrichten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:32:24 +00:00
ClaudeandClaude Opus 5.5 717a3323fa Chat: Team-API und Laden des Chat-Bausteins vom Kundenportal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:31:19 +00:00
ClaudeandClaude Opus 5.5 fce2f41463 Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:44:50 +00:00
ClaudeandClaude Opus 5.5 370724a9a7 Kundenseite: Reiter in der Adresse, Uebersicht mit Umlaut, Protokoll der Kundenansicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:40:42 +00:00
ClaudeandClaude Opus 5.5 8c7a3fcced Team-API des Kundenportals und Oeffnen im neuen Tab ohne Pop-up-Sperre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:39:15 +00:00
ClaudeandClaude Opus 5.5 52968b3017 Analyse: Einschalten im WOMS nur mit Datum und Weg der Zustimmung in Textform
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:02:05 +00:00
ClaudeandClaude Sonnet 5.5 72f07410fe Finanzen: Rueckfrage vor dem Senden, Anschrift des Kunden pruefen, Fehlermeldungen im Bild
Senden einer Rechnung (Dialog und Entwurf in der Tabelle) fragt vorher: "Rechnung fuer
<Kunde> ueber <Betrag> jetzt festschreiben und an <E-Mail> senden? Danach laesst sie sich
nur noch stornieren."

Fehlt dem Kunden Strasse, PLZ oder Ort, zeigen Rechnungsdialog und Kundenansicht die
Warnung "Adresse des Kunden unvollstaendig ..." mit Weg zur Uebersicht, und Senden ist
ueber 250 € gesperrt (§ 14 Abs. 4 Nr. 1 UStG, Kleinbetragsrechnung); Entwuerfe bleiben
moeglich. Reine Helfer addressComplete und invoiceSendGate samt Tests.

Fehlermeldungen in Rechnungs- und Zuordnen-Dialog werden per scrollIntoView
({ block: 'nearest' }) in den sichtbaren Bereich geholt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:22:00 +00:00
ClaudeandClaude Sonnet 5.5 1e72faf97c Finanzen: Portal-Client mit deutschen Fehlertexten fuer Proxy-Fehler und abgelaufene Anmeldung
Der Anfrage-Kern liegt jetzt in financeRequest.js (rein, fetch und createJWT werden
injiziert, unter Node testbar). 502/503/504 ohne JSON-Antwort und Netzfehler melden
"Das Kundenportal ist gerade nicht erreichbar."; scheitert createJWT, kommt
"Anmeldung abgelaufen. Bitte neu anmelden." (status 401). Meldungen des Portals bleiben.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:21:53 +00:00
ClaudeandClaude Sonnet 5.5 e9cfae28ee Finanzen: Steuerkarte mit Orientierungshinweis und neuer Anteilsformulierung, Ladetext auf Deutsch
TaxCard zeigt am Ende immer "Orientierungswert, ersetzt keine Steuerberatung."; der
Anteil heisst "<x> % der Grenze von 25.000 €" statt "<x> % der 25.000-€-Grenze".
AdminRoute zeigt "Wird geladen …" statt "Loading...".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:21:53 +00:00
ClaudeandClaude Sonnet 5.5 0b1101d384 Finanzen: Korrekturen aus dem Oberflaechentest
Seitenmenue startet auf schmalen Bildschirmen (bis 720 px) eingeklappt. Bei 390 px liess
das feste 248-px-Menue nur ~140 px Inhalt, jede Seite (auch /finance) scrollte waagerecht;
das fiel in den Einzeltests ohne App-Rahmen nicht auf.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 edf118a044 Finanzen: Reiter Rechnungen am Kunden nur fuer Admins, Badge Adresse unvollstaendig
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 2aacdb2d56 Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 24a3cc41ef Finanzen: Offen zeigt auch Ueberfaellige, Rechnungsliste laedt nach Zuordnen neu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 1feeb18c08 Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen
Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben")
und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 18bc3012e0 Finanzen: Steuer-Texte zu den Grenzen genauer, Test fuer gleichen Tag
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 3826f1ab72 Finanzen: Vorschau, Steuer und Abos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 88a98b858f Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 0069a62a09 Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 c910bedcec Finanzen: Formatierung, Betragsparser und Portal-API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 8ecf07fecd Analyse: Sperre im WOMS kenntlich machen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:40:06 +00:00
ClaudeandClaude Sonnet 5.5 0cec45d3da Analyse: Schalter im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:38:11 +00:00
ClaudeandClaude Sonnet 5.5 447b7e733e Preview: Dev-Modus nur mit dev-Skript waehlbar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 22:48:21 +00:00
ClaudeandClaude Sonnet 5.5 c1818fadcf Preview: Block ausserhalb des Formulars, Validierung und Feinschliff
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:08:52 +00:00
ClaudeandClaude Sonnet 5.5 2de846436b Preview: Block im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:06:53 +00:00
ClaudeandClaude Sonnet 5.5 0f884e41e8 Preview: Anzeige-Logik und API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:04:31 +00:00
ClaudeandClaude Sonnet 5.5 471c7a056d Projektarten: Login behaelt Ziel-Link, Hinweise im Ticket-Dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:03:40 +00:00
ClaudeandClaude Sonnet 5.5 c16049d266 Projektarten: Art beim Zuweisen und nach Repo-Anlage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:47:02 +00:00
ClaudeandClaude Sonnet 5.5 8653756935 Projektarten: Zuordnen-Seite nachgebessert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:44:55 +00:00
ClaudeandClaude Sonnet 5.5 7e79dac6b4 Projektarten: Bereich Services
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:44:55 +00:00
ClaudeandClaude Sonnet 5.5 8185081921 Projektarten: Zuordnungs-Ablauf im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:42:28 +00:00
ClaudeandClaude Sonnet 5.5 9dc447e92c Projektarten: Ablauf-Logik und API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:39:19 +00:00
ClaudeandClaude Opus 5.5 24e4f054c4 Unterstützung: Anzeige in Arbeitsblatt-Liste und Gesamtzeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:00:07 +00:00
ClaudeandClaude Opus 5.5 bc9d673ff7 Unterstützung: Mitarbeiter tragen sich selbst als Unterstützung ein
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:58:58 +00:00
ClaudeandClaude Opus 5.5 d83bbfb48d Unterstützung: Ersteller trägt helfende Personen im Arbeitsblatt ein
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:57:46 +00:00
ClaudeandClaude Opus 5.5 3535f18a2b Arbeitszeit nur in 15-Minuten-Schritten, berechnete Zeit wird aufgerundet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:55:06 +00:00
ClaudeandClaude Opus 5.5 32bc3d7c3d Unterstützung: Setup-Skript für das Attribut supporters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:48:16 +00:00
ClaudeandClaude Opus 5.5 1497f7a4dc Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:47:36 +00:00
ClaudeandClaude Opus 5.5 564fecdde4 Plan: Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:59 +00:00
ClaudeandClaude Opus 5.5 a336dc07fc Konzept: Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:36:03 +00:00
ClaudeandClaude Opus 5.5 03af5fcfb8 Serverpuls: Zeitzone in Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 57f235dd36 Serverpuls: deutsche Zeitachse und Legende im Diagramm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 16f3150ff1 Serverpuls: JWT-Cache, Lade-Zustand beim Umschalten, Antwort-Reihenfolge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 ec740ca970 Serverpuls: Admin-Seite mit Verlauf und Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 ae905a5d9b Plan: Serverpuls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 3eb67bba81 Spec: Serverpuls (Server-Monitoring fuer Admins)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 6323b830d7 Leads pausiert: Menuepunkt grau und nicht klickbar, /leads leitet auf Tickets um
Die Lead-Automatik ist gestoppt und die automatisch angelegten Kunden
sind geloescht; das Leads-Projekt liegt auf Eis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:25:10 +00:00
ClaudeandClaude Opus 5.5 fa23732217 Rechtliches: jederzeit kuendbare Vertraege brauchen kein Beginn-Datum
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:19:58 +00:00
ClaudeandClaude Opus 5.5 82c2460835 Code-Mail-Vorlage für Appwrite (MFA-Challenge, Deutsch)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 dddfb02d1d Seitenleiste: direkt ins Projekt-Admin wechseln
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 b8e4de97c7 Login mit E-Mail-Code (Appwrite-2FA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 21325c4037 Plan: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 3af8a2f2ad Spec: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:07:00 +00:00
ClaudeandClaude Opus 5.5 3a3e43d5ec Projektliste: nach dem Schließen nicht mehr nachladen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:41:35 +00:00
ClaudeandClaude Opus 5.5 3e3a9a3bbd Inline-Projektliste: neue Repos, Zuweisen mit Umzug, Preview einschalten, Pushes nachtragen
Implementiert auf TicketProjects.jsx (Paket-E-Inline-Liste) statt des
frueheren Dialogs, gemaess Controller-Ruling R3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:41:35 +00:00
ClaudeandClaude Opus 5.5 56489b8d6d Zuweisen über das Portal, Import neuer Repos, Pushes nachtragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:41:35 +00:00
ClaudeandClaude Opus 5.5 cc59e7978e Subdomain-Vorschlag und Zuweisungs-Logik mit Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:41:35 +00:00
ClaudeandClaude Opus 5.5 c24b3d43c8 Rechtliches: Fristen ohne Monatsende-Drift, Indizes und Warten auf Attribute im Setup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:33:56 +00:00
ClaudeandClaude Opus 5.5 c8c1d84794 Rechtliches: Erstbefuellung aus der Git-Geschichte (22 Versionen, 4 Anbieter)
Website-Seiten werden per React zu HTML gerendert, Portal-HTML kommt direkt
aus Git. Eine gemeinsame Datenschutzerklaerung statt zwei Karten, weil
webklar.com/datenschutz nur auf das Portal weiterleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:31:22 +00:00
ClaudeandClaude Opus 5.5 c7c1e338a3 Rechtliches: Abloesung am selben Tag, Kartenraster auf schmalen Bildschirmen
Bei zwei gueltigen Versionen mit gleichem Beginn gilt die zuletzt angelegte;
eine am selben Tag abgeloeste Version endet nie vor ihrem Beginn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:24:18 +00:00
ClaudeandClaude Opus 5.5 67b9439145 Rechtliches: Seite mit Rechtstexten und Anbieter-Vertraegen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:20:54 +00:00
ClaudeandClaude Opus 5.5 31be2af4c3 Rechtliches: Daten-Hook fuer Rechtstexte und Anbieter-Vertraege
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:19:04 +00:00
ClaudeandClaude Opus 5.5 1212173cb4 Rechtliches: Appwrite-Setup-Skript und Konstanten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:18:31 +00:00
ClaudeandClaude Opus 5.5 0105179e21 Rechtliches: Fristen- und Versionslogik mit Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:17:13 +00:00
ClaudeandClaude Opus 5.5 701ff1a8e8 Plan: Bereich Rechtliches in WOMS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:14:45 +00:00
ClaudeandClaude Opus 5.5 f120ec2ef3 Konzept: Bereich Rechtliches in WOMS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:12:34 +00:00
ClaudeandClaude Sonnet 5.5 3b6a821687 Arbeitsnachweis: Abo-Stundenkonto nur bei Kunden mit Wartungs-Abo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:29:45 +00:00
ClaudeandClaude Opus 5.5 b05f49621c Kontextbox im Ticket: andere Tickets gut lesbar
Die Links auf die anderen Tickets des Kunden hatten keine eigene Farbe und
erschienen im dunklen Design in Browser-Blau (#0000ee), kaum lesbar, dazu
unterstrichen. Sie nutzen jetzt die normale Textfarbe ohne Unterstreichung,
wie die übrigen Listenzeilen (.list-row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:44:29 +00:00
ClaudeandClaude Opus 5.5 7b31cb5368 Roadmaps aus WOMS entfernt
Menüpunkt, die Seiten /roadmaps und /roadmaps/:id, der Roadmap-Kasten im Ticket
und die Plan-Automatik (Auto-Ticket, Knoten-Kommentare, automatischer Abschluss)
sind entfernt. Alte Links landen bei den Tickets. Die Projekt-Kontextbox im
Ticket bleibt und zeigt das zugewiesene Projekt samt den anderen Tickets des
Kunden, nur ohne Plan-Link. Die Daten in roadmaps/roadmapNodes bleiben in
Appwrite unangetastet; das Kundenportal liest sie nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:39:25 +00:00
ClaudeandClaude Opus 5.5 50d237a932 Projekte im Ticket als eine Liste (Paket E)
- Im Details-Reiter steht eine Projektliste mit Zuweisen, Lösen und
  "Neues Projekt"; der Knopf "Projekte zuweisen / bearbeiten", der
  Dialog und die zweite Projektliste sind weg.
- Zuweisen bietet nur passende Projekte an: gleicher Kunde oder ohne
  Kunde, nicht archiviert, keine Vorlage.
- Lösen nimmt ein Projekt nur noch vom Ticket. Bisher wurde dabei auch
  die Kundenzuordnung gelöscht und der Kunde sah seine Vorschau im
  Portal nicht mehr.
- Projektkarten ohne README-Vorschau (kostete je Karte einen Abruf) und
  ohne nie befüllte Felder (Live-Domain, Commit-Box, "Preview: aktiv");
  react-markdown entfällt damit (Lockfile mit Node 20 erzeugt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 92fdfdc961 Leads ohne eingefrorene Status (Paket E)
Die Status-Reiter, die Zahlen "Neu"/"Gewonnen" und das Status-Abzeichen
lasen crmStatus, das seit dem 16.07. niemand mehr pflegt (gearbeitet
wird im Akquise-Ticket). Suche, Sucheinstellungen, Pipeline und
Lead-Karten bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 5a066eb3c4 Kunden an einer Stelle pflegen (Paket E)
- Kundenliste startet bei "Feste Kunden" (105 von 110 Einträgen sind
  Leads der Lead-Automatik); Reihenfolge Feste Kunden, Leads, Alle, Archiv.
- Der zweite Kunden-Editor im Admin ist weg. Admins legen feste Kunden
  mit Portal-Zugang jetzt unter Kunden an ("Neuer Kunde") und löschen sie
  auf der Kundenseite ("Kunde löschen", mit Rückfrage); beides wie bisher
  über die Admin-API des Kundenportals. Die Kundenseite hat das Feld
  Standort, das es bisher nur im Admin gab.
- Die Kundenliste liest direkt aus Appwrite: die Admin-API lieferte
  Admins gekürzte Daten, Stern und InvoiceNinja-Abzeichen fehlten dort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 31144148d1 Menü als flache Liste (Paket E)
Acht Einträge ohne Gruppenüberschriften: Tickets, Planboard, Roadmaps,
Projekte, Kunden, Leads, Finanzen und für Admins Admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 1a8604f7e6 Finanzen nur noch anzeigen, Dashboard und Admin-Konfiguration entfernt (Paket D)
- Finanzen: Liste, Zahlen, PDF und Link zu InvoiceNinja bleiben. Das
  Formular "Rechnung erstellen" (auf der Seite und als "Rechnung aus
  Ticket erstellen" im Ticket) ist weg; Rechnungen entstehen direkt in
  InvoiceNinja. Das Verknüpfen von Kunden mit InvoiceNinja bleibt.
- Filter "Bezahlt" repariert: InvoiceNinja liefert status_id als Text.
- FinancePage.jsx und InvoicePanel.jsx waren Windows-1252 kodiert und
  zeigten live "�"; jetzt UTF-8, dazu Umlaute in den Texten.
- Dashboard entfernt (doppelte Finanzzahlen, "Offen" zählte falsch);
  alte Links führen zu den Tickets.
- Admin: Reiter "Konfiguration" (nur noch Prioritäten, die seit Paket B
  niemand liest) mit useAdminConfig entfernt; Admin startet bei Kunden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:01:12 +00:00
ClaudeandClaude Opus 5.5 34c4db0228 Dokumente und Paperless aus WOMS entfernt (Paket C)
Paperless enthält 0 Dokumente, der Upload wurde nie benutzt. Weg sind:
die Seite Dokumente (alte Links führen zu den Tickets), der Menüpunkt,
der Reiter Dokumente in Ticketkarte und Kundenseite, der Upload-Dialog
(legte Dateien zusätzlich ungelesen im Appwrite-Bucket ab), das Feld
"Paperless-Korrespondent" und das Abzeichen "Docs", auf dem Dashboard
die Kachel Dokumente und "Dokument hochladen", im API-Client die
Paperless-Aufrufe. Gespeicherte Daten bleiben unverändert; die
Paperless-Routen im Backend ticket-integrations sind nicht angefasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:52:46 +00:00
ClaudeandClaude Opus 5.5 062d992b63 Tickets auf WEBklar zugeschnitten (Paket B)
- Status: 6 statt 10, auf Deutsch (Offen, In Arbeit, Wartet auf Kunde,
  Prüfung, Erledigt, Storniert). Gespeichert werden die Werte, die das
  Kundenportal für die Fortschrittsleiste kennt; alte Werte wie Assigned
  werden weiter angezeigt ("Eingeplant"), sind aber nicht mehr wählbar.
- Zuweisen ändert den Status nicht mehr; neue Tickets starten "Offen".
- Priorität: Schalter "Dringend" statt fünf Stufen (dringend = 4, normal
  = 2; alte Stufen Hoch/Kritisch gelten als dringend), rotes Abzeichen.
- Ticketliste: Standardansicht "Aufträge" ohne Akquise-Tickets; Filter
  Typ (Aufträge/Webpage/Migration/Projekt/Akquise/Alle), 6 Status-Gruppen,
  "Nur dringende". Die toten Felder Kunde, Benutzer/Thema und Datum sind
  durch eine echte Suche ersetzt (Kunde, Betreff, Angefragt von, WOID).
  Deep-Link ?woid= zeigt das Ticket weiter unabhängig von Art und Status.
- Formulare "Neues Ticket" und "Arbeitsblatt" auf Deutsch; raus sind
  Workload Estimate, Requested for und die Service-Art am Ticket
  (gespeichert wird weiter Fernwartung).
- Ticketkarte: Projekt/Preview im Details-Reiter (4 statt 5 Reiter), eine
  Zusammenfassungszeile statt der großen Statistik, doppelter
  Projekte-Stift weg, Service-Abzeichen weg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:46:03 +00:00
ClaudeandClaude Opus 5.5 bf007df737 Abo-Umschalter in der Arbeitsblatt-Liste eingebaut
Der Knopf "+ Abo" / "✓ Abo" (Arbeitszeit vom Stundenguthaben des
Wartungs-Abos abziehen) war seit cf1d653 nur in der toten Ticketzeile
angeschlossen und deshalb nie sichtbar. Jetzt hängt er an der echten
Ticketkarte: bei jedem Arbeitsblatt mit Arbeitszeit, auch bei Git-Pushes
mit nachgetragener Zeit; Kommentare und offene Pushes haben keinen.
Der Hinweis "Abo-Stunden" ist jetzt lesbar (weiße Schrift, Abstand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:56:53 +00:00
ClaudeandClaude Opus 5.5 7b33e3900d Ungenutzte Pakete entfernt
three, postprocessing, motion, clsx, tailwind-merge sowie @types/react
und @types/react-dom brauchte nur der gelöschte tote Code (bzw. es gibt
kein TypeScript). Das spart bei jedem Deploy die Installation von
three.js & Co. Lockfile mit Node 20 (wie der Deploy-Container) erzeugt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:42:45 +00:00
ClaudeandClaude Opus 5.5 7852593a7a Veraltete Anleitungen entfernt, README neu
20 Einrichtungs- und Fix-Notizen vom Dezember 2025 beschrieben das alte
Appwrite (cloud.appwrite.io bzw. alte Projekt-IDs), zufällige WOIDs und
Felder, die es so nicht mehr gibt. Das README beschreibt jetzt den echten
Stand: Deploy über Branch test (Achtung: jeder gepushte Branch geht live),
Datenmodell in Kurzform und das Zusammenspiel mit Kundenportal und
ticket-integrations. LOCAL_DEV.md bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:41:57 +00:00
ClaudeandClaude Opus 5.5 df62bcbc55 Schrift Lato vom eigenen Server statt von Google Fonts
Bisher lud jeder Seitenaufruf Lato und das ungenutzte Open Sans von
fonts.googleapis.com; dabei gingen die IP-Adressen der Mitarbeiter an
Google. Jetzt liegt Lato Regular 1.105 (SIL Open Font License, Lizenz
daneben) im Repo. Gemessen gegen Google: gleiche Textbreite und
Zeilenhöhe; wie bisher nur der Schnitt 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:40:43 +00:00
ClaudeandClaude Opus 5.5 b758b1f9a7 Demo-Modus entfernt
isDemoMode war nie aktiv: appwrite.js hat immer eine Projekt-ID als
Fallback. Weg sind die Demo-Zweige in Anmeldung und Hooks sowie die
Beispieldaten aus der alten IT-Welt (Serverausfall, Drucker, Kunde A–E).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:36:33 +00:00
ClaudeandClaude Opus 5.5 889a2f42eb Vergessenen Debug-Aufruf an 127.0.0.1 entfernt
AuthContext schickte bei jedem Seitenaufruf einen Debug-Eintrag an
http://127.0.0.1:7284 (Überbleibsel einer Fehlersuche). Live landete er
im Nichts; neuere Browser können für solche Aufrufe ins lokale Netzwerk
eine Rückfrage anzeigen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:34:16 +00:00
ClaudeandClaude Opus 5.5 d684aea576 Toten Code entfernt
Nie importierte Dateien (rund 1.900 Zeilen): die alte Ticketzeile
TicketRow mit ihren Hilfsdialogen EditWorksheetTimeModal und
ResponseDropdown, TicketFilters, WebpageProjectPanel, ModernSidebar
(+ lib/utils), die three.js-Animation PixelBlast und createDummyTicket.
Dazu die Helfer, die nur diese Dateien benutzt haben
(isTemplateProject/getAvailableProjects, WEBPAGE_TICKET_TYPE,
PREVIEW_TEMPLATE_NAME), die nicht existierenden Collections users und
attachments, getWorksheetsByEmployee und der nur von TicketRow benutzte
zweite "Zeit nachtragen"-Weg in der Arbeitsblatt-Liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:33:37 +00:00
ClaudeandClaude Opus 5.5 8d2a99e5d6 Schnellübersicht und Status-Verlauf entfernt
- "Schnelluebersicht" war eine englische Alt-Tabelle ("WOMS Ticket Quick
  Overview ... EOL =)"), die nur die Ticketliste wiederholte.
- Der "Verlauf"-Knopf zeigte nur Statuswechsel aus Arbeitsblättern (bisher
  zwei); jeder Wechsel steht weiterhin im jeweiligen Arbeitsblatt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:32:43 +00:00
ClaudeandClaude Opus 5.5 cbf77004ad Platzhalter-Seiten Assets, Reports und Docs entfernt
Die drei Seiten hatten keine Funktion (feste Nullen, ein alert(),
drei Beispielsätze). Alte Links und unbekannte Adressen führen jetzt
zu den Tickets statt auf eine leere Seite; Menügruppen ohne Eintrag
werden nicht mehr angezeigt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:32:17 +00:00
ClaudeandClaude Opus 5.5 2d4e569cc3 Arbeitsblätter: Git-Push nach dem Nachtragen grün, Kommentare blau
- Git-Pushes bleiben orange mit "Zeit offen", bis Beginn und Minuten
  nachgetragen sind; danach grün mit "Zeit eingetragen".
- Kommentare sind blau statt grün, damit Grün nur noch "erledigt" heißt.
- Hinweise im farbigen Kopf in weißer Schrift (vorher kaum lesbar).
- Pro Ticket werden alle Arbeitsblätter geladen (Appwrite lieferte ohne
  limit nur 25), zugeklappte Ticketkarten laden gar keine mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:29:36 +00:00
ClaudeandClaude Opus 5.5 0c884a57d7 Arbeitsblätter: Zeit für eigene Git-Pushes nachtragen
70 von 78 Arbeitsblättern sind Git-Pushes, die das Kundenportal mit
employeeId "gitea" anlegt; in employeeName steht der Gitea-Name des
Pushers ("knso", "ANDJ", "JUSN" oder der volle Name). Sie gehören dem
Mitarbeiter mit diesem Kürzel oder Namen - der eigene employees-Eintrag
kommt jetzt aus dem AuthContext.

Bei offenen Pushes heißt der Button "Zeit nachtragen": Beginn ist Pflicht,
das Ende ist der Push. Beim Speichern wird isComment false, damit die Zeit
in der Arbeitszeit zählt (wie früher im Modal der alten Ticketzeile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:55:34 +00:00
ClaudeandClaude Opus 5.5 149b38b94b Arbeitsblätter: Zeiten nachträglich bearbeiten, nur für den Ersteller
Eigene Arbeitsblätter (employeeId = angemeldeter Benutzer, kein reiner
Kommentar) bekommen im Kopf den Button "Zeiten bearbeiten". Der Dialog
ändert Beginn, Ende und Arbeitszeit; die Minuten rechnen wie beim Anlegen
mit, von Hand eingetragene Minuten bleiben stehen. Gespeichert werden nur
die fünf Zeitfelder, wieder als dd.mm.yyyy / hhmm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:36:19 +00:00
ClaudeandClaude Opus 5.5 e678899141 Arbeitsblatt-Formular erneuert: Kalender, deutsche Service-Art
- Start und Ende mit Kalender und Uhrzeitfeld (gemeinsame Komponente
  DateTimeInput, auch im Ticketformular); gespeichert weiter als
  dd.mm.yyyy / hhmm, das Ende rückt mit, wenn der Start später liegt
- Service Type nur noch "Fernwartung (remote)" oder "Vor Ort beim Kunden",
  vorbelegt vom Ticket; COMMENT entfällt (dafür gibt es "Nur Kommentar")
- Minuten werden aus den Uhrzeiten berechnet (über Mitternacht weiter)
- Minutenfeld nimmt ganze Minuten: mit step 15 ließ der Browser berechnete
  Zeiten wie 70 Minuten nicht absenden
- Arbeitsblatt-Liste und -Statistik zeigen die Service-Art auf Deutsch
  (Remote und altes Off Site zusammen als Fernwartung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:59:07 +00:00
ClaudeandClaude Opus 5.5 8cb0bb8146 Affected System und Response Level aus der Oberfläche entfernt
- Arbeitsblatt: Feld "New Response Level" entfernt; ein Statuswechsel
  schreibt am Ticket nur noch den Status
- Arbeitsblatt-Verlauf: Zeile "Response Level: alt -> neu" entfernt
- Ticketkarte: System-Badge alter Tickets entfernt
- Schnellübersicht: Spalte "System" entfernt
- Ticketfilter: Auswahl "System" entfernt (wurde ohnehin nie abgefragt)
- Kundendetails: Ticketzeile zeigt nur noch die Art statt "Art · System"

Die Werte bleiben in der Datenbank erhalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:45:12 +00:00
ClaudeandClaude Opus 5.5 d6becf0151 Ticketkarte und Admin an das neue Formular angepasst
- Ticketkarte: Response-Level-Auswahl entfernt; Service Type als
  "Fernwartung" / "Vor Ort" (altes "Off Site" zählt als Fernwartung)
- Admin > Konfiguration: Ticketarten, Systeme, Reaktions- und Leistungsarten
  ausgeblendet, da das Formular sie fest vorgibt; editierbar bleiben die
  Prioritäten, die Listen bleiben beim Speichern im Config-Dokument erhalten
- Kaputtes Trennzeichen "�" wieder "·" (Ticketkarte, Kundenliste, Kundendetails)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:39:13 +00:00
ClaudeandClaude Opus 5.5 d86f9c07cd Neues Ticket: Formular entschlackt, Kalender und Autovervollständigung
- Work Order Type nur noch Webpage und neu Migration (Kunde kommt von anderem Anbieter)
- Affected System, Response Level und Benachrichtigungsfelder entfernt
- Service Type: Fernwartung (remote) oder Vor Ort beim Kunden, Standard Fernwartung;
  gespeichert werden weiter "Remote" / "On Site"
- Start und Ende mit Kalender und Uhrzeitfeld; gespeichert weiter als dd.mm.yyyy / hhmm
- Requested by/for schlagen Kunden, Mitarbeiter und Namen aus den letzten Tickets vor,
  die des gewählten Kunden zuerst
- Neue Tickets speichern Kundenname und Ort (die Karte zeigte sonst "Unbekannt")
- Ticketfilter kennt Webpage und Migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:30:29 +00:00
ClaudeandClaude Opus 5.5 cf1d653eb2 Wartungs-Abo: Worksheets als Abo-Stunden markieren
Haken beim Anlegen + Umschalter in der Worksheet-Liste setzen
worksheets.countsToPlan. Nur markierte Arbeitszeit wird im Kundenportal
vom Abo-Stundenguthaben abgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:04:44 +00:00
ClaudeandClaude Opus 5.5 d42ee939e9 Kundenliste: Fallback laedt alle Kunden statt nur 25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 18:58:13 +00:00
ClaudeandClaude Opus 5 19464fe20d Planboard: echte Termine aus den Tickets statt leerer Wochenansicht
Das Planboard war eine Huelle - sieben Spalten mit "Keine Termine". Es zeigt
jetzt die terminierten Tickets der Woche: Uhrzeit, Kunde, Betreff, Kuerzel des
Zustaendigen. Klick fuehrt ueber den vorhandenen Deep-Link /tickets?woid=... ins
Ticket. Heute ist hervorgehoben, "Heute" springt zurueck.

Ein Termin ist bewusst kein eigener Datensatz, sondern die Ticketfelder
startDate/startTime/endTime/assignedTo. Die Rechnungsanalyse im Kundenportal
schreibt genau diese Felder, deshalb steht ein dort vereinbarter Termin ohne
Umweg hier - und Ticket und Termin koennen nicht auseinanderlaufen.

Die Aufbereitung liegt in src/lib/planboard.js, damit sie ohne UI testbar ist:
Datum kommt als Text und real auch einstellig ("22.5.2026"), Uhrzeit als
"hhmm"; beides wird tolerant gelesen, Unsinn faellt raus statt zu verrutschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:09:22 +00:00
ClaudeandClaude Fable 5 98601b4b3d feat(roadmaps): Ticket-Integration — Roadmap-Panel + Projekt-Kontextbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:01:21 +00:00
ClaudeandClaude Fable 5 3cc6ab3343 feat(roadmaps): Steckbrett-Board mit SVG-Kanten, Detail-Panel und Abschluss-Automatik
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:59:37 +00:00
ClaudeandClaude Fable 5 d7f9cf75a3 feat(roadmaps): Hooks, Routing, Sidebar-Tab und Übersichtsseite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:55:47 +00:00
ClaudeandClaude Fable 5 5361800ca9 feat(roadmaps): Kernlogik + Appwrite-Konstanten für projektspezifische Pläne
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:53:16 +00:00
ClaudeandClaude Opus 4.8 52713fb481 feat: Leads-Karten sind reine Info-Anzeige (Status/Notiz-Editing entfernt)
Leads-Tab zeigt/verfolgt nur noch die Automatisierung; Status & Doku laufen im Akquise-Ticket (WOID-Chip). Alte Notizen bleiben read-only sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:10:47 +00:00
ClaudeandClaude Opus 4.8 e59a066d3c feat: Leads — Button zur erstellten Vorschau-Website
- Vorschau-Button (Auge, primary) je Lead -> previewUrl der deployten Website
- previewUrl live gejoint (Lead->Kunde per E-Mail->websiteProjects status deployed/ready); Preview haengt an anderem Repo als lead.repoUrl -> Kunden-Join
- attachWoids -> attachLeadLinks (haengt jetzt woid + previewUrl an)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:53:38 +00:00
ClaudeandClaude Opus 4.8 fe3e39bd04 feat: Leads-Aktionsbuttons icon-only + WOID des Akquise-Tickets
- Maps/Website/Repo nur noch Icon (title/aria-label bleiben)
- WOID je Lead als klickbarer Chip -> /tickets?woid= (Lead->Kunde->Akquise-Ticket, live in useLeads gejoint)
- useWorkorders: woid server-seitig filterbar (macht den bisher wirkungslosen WOID-Filter nutzbar)
- TicketsPage: ?woid= Deep-Link liest Param, filtert ueber alle Status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:40:40 +00:00
knsoandClaude Fable 5 4c305fcd46 feat: Pipeline-Status waiting_enrichment sichtbar (Label + amber Segment)
Leads parken jetzt nach Schritt 3 am Anreicherungs-Gate, bis die taegliche
Cloud-Routine Bilder/Logo/Text-Feinarbeit liefert (POST /webklar-hook/enrich).
PipelineBar zeigt dafuer ein eigenes Label statt des (manuell)-Fallbacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:06:15 +00:00
ClaudeandClaude Fable 5 0b24596f15 feat: Lead-Pipeline sichtbar - 5-Min-Zyklusbalken + Refresh-Trigger, Schritt-Fortschritt pro Lead, Pipeline-Log & KI-E-Mail-Entwurf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:57:18 +00:00
knsoandClaude Fable 5 1cec319fa8 feat: CRM-Tab Leads - Lead-Visualisierung aus Appwrite + Sucheinstellungen der 06:00-Routine
- Neue Seite /leads unter CRM: Lead-Karten (Score, Google-Bewertung,
  Website-Status, Kontakt, Maps/Website/Repo-Links), KPI-Zeile,
  Status-Workflow (neu/kontaktiert/interessiert/gewonnen/abgelehnt),
  Notizen, Portal-Zugangsdaten
- Sucheinstellungen (Branche, Staedte oder ganz Deutschland, max. Leads
  pro Stadt, Mindestbewertung, E-Mail-Pflicht, Website-Kriterium,
  Zusatzhinweise) -> Appwrite leadSettings, gelesen von der Routine
  ueber webklar-hook /config
- Ersetzt die Excel-Masterliste als Lead-Ansicht

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:09:23 +00:00
ClaudeandClaude Opus 4.8 5786eed3ba feat: Projektkarten-Aktionen nur als Icons (Bearbeiten/Hosten/Aktualisieren)
Aktualisieren-Button ohne Text-Label, konsistent zur Toolbar; Spinner
auch beim Hosten. Tooltips via title/aria-label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:04:41 +00:00
ClaudeandClaude Opus 4.8 5270084ff0 feat: Aktualisieren-Button auf Projektkarten (Re-Deploy gehosteter Projekte)
Dritter Aktions-Button neben Bearbeiten/Vorschau: stoesst fuer bereits
gehostete/deployte Projekte den Deploy erneut an (updateWebsiteProject
mit gleichem Typ), damit eine ueberarbeitete Website neu geladen wird.
Spinner waehrend der Aktualisierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:54:31 +00:00
ClaudeandClaude Opus 4.8 8855fe92ba feat: Projektseite-Toolbar - Icon-Buttons + Textsuche + Kunden-/Mitarbeiter-Filter
- Sync/Neu als Icon-Buttons (Tooltip statt Text), Sync-Icon dreht beim Laden
- Textsuche nach Titel/Subdomain/Repo
- Filter nach Kunde (Dropdown)
- Filter nach Mitarbeiter (ueber zugeordnetes Ticket -> assignedTo)
- alle Filter kombinierbar mit der bestehenden Kategorie-Auswahl

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:44:57 +00:00
ClaudeandClaude Opus 4.8 612063455e feat: Jetzt-hosten-Button + Deploy-Status auf Projektkarten
- Karten zeigen fuer Hosting-Typen (Preview/Website/Vorlage) einen Status:
  gehostet (ready/deployed) vs nicht deployt (synced etc.)
- nicht-deployte Projekte: Button "Jetzt hosten" -> deployt via PATCH-Route
  (setzt status=deployed/ready), danach funktioniert die Preview
- deployte Projekte zeigen weiterhin den Preview-Link

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:32:04 +00:00
ClaudeandClaude Opus 4.8 9e4c60fdaf feat: Vorlagen-Ansicht (Admin) + Typ Vorlage in Projektseite
- neuer Typ Vorlage (nur Admin waehlbar), Badge, isTemplate
- Dropdown-Filter Vorlagen nur fuer Admins; Vorlagen aus normalen Ansichten ausgeblendet
- in Vorlagen-Ansicht: Vorlagen sehen + previewen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:10:25 +00:00
knso 86fb24b1a9 Update preview config from ticket system 2026-07-12 09:02:34 +00:00
ClaudeandClaude Opus 4.8 6c3f03b5b4 feat: Website-Projekte als Karten + Bearbeiten-Modal + Archiv-Filter
- Tabelle -> responsives Karten-Grid (kein Excel-Look), Typ-Badges
- Bearbeiten (Stift): Name, Subdomain, Typ (Preview/Oeffentliche Website/Internes Projekt)
  -> PATCH synct Git + Preview + Appwrite
- Repository im Bearbeiten-Modus archivieren/reaktivieren
- Dropdown-Filter Archiviert; archivierte Projekte standardmaessig ausgeblendet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:23:45 +00:00
knsoandClaude Fable 5 42f729abfc feat: Kunden archivieren + Ticket-Status-Checkbox-Filter
Kunden:
- Neues Feld customers.archived; Archivieren/Wiederherstellen-Button in der
  Kundendetailseite, 'Archiviert'-Badge
- CustomersPage: vierter Filter-Tab 'Archiviert'; archivierte Kunden aus
  Alle/Leads/Feste Kunden ausgeblendet, nur im Archiv-Tab sichtbar

Tickets:
- Status-Mehrfachauswahl per Checkbox im Filterbereich; Default alle Status
  ausser 'Closed' (= archiviert). 'Standard' / 'Alle inkl. Archiv' Shortcuts
- useWorkorders: Statusfilter serverseitig via Query.equal(array) statt
  Client-Filter nach dem Limit (behebt Bug, bei dem archivierte Tickets die
  neuesten Slots belegten und aktive verdraengten)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:44:08 +00:00
knsoandClaude Fable 5 ff536b048f feat: Zeit nachtragen fuer Git-Push-Worksheets
Git-Pushes erzeugen Worksheets mit automatischer Endzeit (Push-Zeitpunkt),
aber ohne Startzeit. Neu:
- WorksheetList: Pille 'Zeit offen' + Button 'Zeit nachtragen' bei
  GIT-Worksheets ohne Startzeit/Arbeitszeit
- EditWorksheetTimeModal: Startdatum/-zeit erfassen, Arbeitszeit wird live
  berechnet (inkl. Uebernacht-Logik), speichert via updateWorksheet und setzt
  isComment=false, damit der Eintrag in der Arbeitszeit-Statistik zaehlt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:10:10 +00:00
knsoandClaude Fable 5 4be20237a1 feat: Projekte ohne Subdomain, Portal-Zugang unter Uebersicht, Kunden-Zuordnung
- ProjectsPage: Subdomain optional (ohne = normales Projekt, nur Gitea-Repo);
  Kunde direkt in der Tabelle zuordnen; Button 'Gitea-Repos synchronisieren'
- CustomerDetailPage: Card 'Portal-Zugang' unter Uebersicht - Passwort setzen/
  aendern (min. 8 Zeichen), Login+Passwort kopierbar, Link zum Kundenportal
- leads.js: uebrig gebliebene Debug-Instrumentierung entfernt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:40:10 +00:00
Webklar Deploy f76b738f5f feat: rebuild admin account management and ticket assignment 2026-07-11 15:19:54 +00:00
Webklar DeployandCursor 27b365622e fix: Lead-Anlage - ungueltiges createdAt-Attribut entfernt (+temp log)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:35:13 +00:00
Webklar DeployandCursor a1cb90372f feat: CRM-Stufen (Lead/Kunde) + InvoiceNinja-Sync (eine Pflege)
- Zwei Kundenstufen: Lead (potenziell) und fester Kunde; Lead braucht nur
  Unternehmen + E-Mail und erzeugt automatisch ein Akquise-Ticket
- Akquise-Worksheet-Ergebnis (In Kontakt/Zugesagt/Abgesagt) sichtbar in WSIDs;
  "Zugesagt" macht den Lead automatisch zum festen Kunden, "Abgesagt" -> verloren
- Kunde wird nur in Appwrite gepflegt und automatisch als InvoiceNinja-Client
  angelegt/aktualisiert (beim Upgrade bzw. bei Datenpflege)
- Rechnungen nur fuer feste Kunden mit vollstaendigen Daten (Strasse/PLZ/Stadt)
- Kundenliste mit Stufen-Filter, neue Rechnungs-/Adressfelder

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:18:28 +00:00
Webklar DeployandCursor 7d764ce6bc chore: Debug-Instrumentierung aus TicketCard entfernt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:54:28 +00:00
Webklar DeployandCursor 1006824375 fix: Ticket-Dropdown nicht mehr abgeschnitten (overflow visible auf .tcard)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:49:53 +00:00
Webklar DeployandCursor 43a8064ad5 debug: instrument dropdown clipping in TicketCard (temp)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:45:48 +00:00
Webklar DeployandCursor d20a4de117 feat: Komplett-Redesign + Ausbau zur Plattform
- Ruhiges Dark-Designsystem (Tokens, Karten, Badges/Pills, KPIs) in global.css,
  animierter PixelBlast-Hintergrund entfernt (kleineres Bundle)
- Aufgeraeumter App-Rahmen: feste Sidebar mit sichtbaren Labels, gruppierter
  Navigation, aktivem Zustand und Collapse
- Wiederverwendbare UI-Bausteine unter src/components/ui/
- Ticketliste als scanbare Karten (TicketCard) statt rowSpan-Tabelle
- Neue Bereiche: Kunden (CRM-360), Finanzen-Cockpit, Dokumente-Browser
- Dashboard als echtes Cockpit (Ticket-/Finanz-/Dokument-Kennzahlen)
- Alle Seiten auf einheitlichen PageHeader/Container/Tokens umgestellt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:41:33 +00:00
Webklar DeployandCursor 8f6927fbb0 feat: Rechnungen, Dokumente und Preview im Ticket buendeln
- InvoiceNinja-Rechnungen pro Kunde/Ticket anzeigen, verknuepfen und aus
  Ticket heraus erstellen (integrationsApi, useInvoices, InvoicePanel)
- Paperless-ngx: Ticket-Uploads automatisch synchronisieren und Kunden-/
  Ticket-Dokumente durchsuchen/anzeigen (DocumentsPanel, FileUploadModal)
- Projekt-/Preview-Karte mit Status, Provisioning und letztem Commit
- Einheitliches Ticket-Detail mit Tabs (Details/Arbeitsblaetter/Rechnungen/
  Dokumente/Projekt) + zugehoerige Styles

Aufruf des Integrations-Backends same-origin per Appwrite-JWT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:13:35 +00:00
Webklar DeployandCursor 8abf11ad18 Gitea-Projektintegration im Ticket-Frontend dauerhaft einbinden.
Projekt-Zuweisung, README-Anzeige und Admin-API-Hooks ins Repo committen, damit deploy.sh die Änderungen nicht mehr verwirft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:54:46 +00:00
knso 4a2e94bc83 passwort anzeigen 2026-05-25 17:10:09 +02:00
Webklar DeployandCursor fcd13e6a40 Admin: Kunden mit Portal-Passwort für project.webklar.com anlegen.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 06:47:01 +00:00
Webklar DeployandCursor fda673702e Stelle Website-Projekte und Admin-Tabs nach Auto-Deploy-Reset wieder her.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 06:41:35 +00:00
knso 8d62e353cb fix 2026-05-22 21:37:08 +02:00
Webklar DeployandCursor f5257cd251 Behebt Demo-Modus: committete Env-Dateien und Project-ID-Fallback
.env.development/.env.production und zentraler isDemoMode verhindern
leere VITE_APPWRITE_PROJECT_ID nach git clone ohne lokale .env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 19:33:14 +00:00
knso 73d6c50b8d gitignor 2026-05-22 21:20:13 +02:00
Webklar DeployandCursor f174421c63 Vite-Proxy: direkt Appwrite, längere Timeouts gegen 504
Proxy-Ziel appwrite.webklar.com, 120s Timeout und 504-Hinweise in LOCAL_DEV.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:56:25 +00:00
Webklar DeployandCursor d2e7088146 Vite-Proxy für lokale Appwrite-Entwicklung
Leitet /v1 an ticket.webklar.com weiter und dokumentiert .env.local,
damit Session-Cookies auf localhost wie auf dem Server funktionieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:23:37 +00:00
Webklar DeployandCursor 4f4de7f290 Fix Appwrite-Session und Collection-IDs für Ticket-System
Nutzt String-Collection-IDs, ticket.webklar.com/v1 als Default und
korrigiert isAdmin sowie Session-Handling für Same-Origin-Login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:58:11 +00:00
JUSN 5fbb2fb4b5 Merge pull request 'das war WSID upgrade' (#5) from better-design into test
Reviewed-on: #5
2026-01-06 00:02:42 +00:00
JUSN cb110a184b Merge pull request 'better-design' (#4) from better-design into test
Reviewed-on: #4
2026-01-05 23:49:51 +00:00
JUSN ee7c866616 Merge pull request 'design update' (#3) from better-design into test
Reviewed-on: #3
2025-12-30 00:31:06 +00:00
277 changed files with 24178 additions and 8486 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Öffentliche Vite-Variablen für lokale Entwicklung (in Git, kein Secret)
VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
VITE_APPWRITE_PROJECT_ID=6a1058610003c5a13a05
VITE_APPWRITE_DATABASE_ID=woms-database
VITE_APPWRITE_BUCKET_ID=woms-attachments
+14
View File
@@ -0,0 +1,14 @@
# Kopieren nach .env.local (wird nicht von Git versioniert):
# cp .env.example .env.local
#
# Lokal mit Vite-Proxy (empfohlen � Session-Cookies wie auf dem Server):
VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
VITE_APPWRITE_PROJECT_ID=6a1058610003c5a13a05
VITE_APPWRITE_DATABASE_ID=woms-database
VITE_APPWRITE_BUCKET_ID=woms-attachments
# Optional: Proxy-Ziel (Standard: https://appwrite.webklar.com)
# VITE_APPWRITE_PROXY_TARGET=https://appwrite.webklar.com
# Produktion / Build (ohne Vite-Proxy):
# VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1
+5
View File
@@ -0,0 +1,5 @@
# Öffentliche Vite-Variablen für Production-Build (in Git, kein Secret)
VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=6a1058610003c5a13a05
VITE_APPWRITE_DATABASE_ID=woms-database
VITE_APPWRITE_BUCKET_ID=woms-attachments
+2 -2
View File
@@ -1,10 +1,10 @@
# Dependencies
node_modules/
# Environment variables
# Environment variables (persönliche Overrides — nicht committen)
.env
.env.local
.env.production
.env.*.local
# Build output
dist/
+9
View File
@@ -0,0 +1,9 @@
{
"enabled": false,
"type": "static",
"branch": "test",
"displayName": "tickte-system",
"templateName": "webklar-preview-template",
"projectType": "project",
"public": false
}
-116
View File
@@ -1,116 +0,0 @@
# Admin-Panel Setup
Das Admin-Panel wurde erfolgreich erstellt! Hier ist die Anleitung zur Einrichtung.
## Was wurde erstellt:
1. **Admin-Panel Seite** (`/admin`) - Verwaltung der Dropdown-Optionen
2. **useAdminConfig Hook** - Lädt und speichert die Konfiguration
3. **Navigation erweitert** - Admin-Link erscheint für Admin-Benutzer
4. **CreateTicketModal angepasst** - Verwendet jetzt die konfigurierten Werte
## Schritt 1: Config Collection in Appwrite erstellen
1. Gehe zu `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Databases** → **woms-database**
4. Klicke auf **Create Collection**
5. **Collection ID**: `config`
6. **Name**: `Admin Configuration`
### Attribute hinzufügen:
| Attribut Name | Typ | Größe | Required | Array |
|--------------|-----|-------|----------|-------|
| `ticketTypes` | string | - | - | ✓ |
| `systems` | string | - | - | ✓ |
| `responseLevels` | string | - | - | ✓ |
| `serviceTypes` | string | - | - | ✓ |
| `priorities` | string | - | - | ✓ |
**Wichtig:** Alle Attribute müssen als **Array** konfiguriert sein!
### Berechtigungen:
- **Read**: `Any` (damit alle Benutzer die Optionen sehen können)
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
- **Delete**: `Users` (optional)
## Schritt 2: Admin-Berechtigung konfigurieren
Aktuell wird ein Benutzer als Admin erkannt, wenn:
- Die Email `admin` enthält, ODER
- Die Email `kenso@webklar.com` ist, ODER
- Der Benutzer ein `admin` Label hat
### Option A: Email-basierte Erkennung (aktuell implementiert)
Die Admin-Erkennung erfolgt in:
- `src/pages/AdminPage.jsx` (Zeile ~10)
- `src/components/Navbar.jsx` (Zeile ~29)
Du kannst die Bedingung anpassen:
```javascript
const isAdmin = user?.email?.includes('admin') ||
user?.email === 'deine@email.com' ||
user?.labels?.includes('admin')
```
### Option B: Appwrite Labels verwenden
1. Gehe zu **Auth** → **Users** im Appwrite Dashboard
2. Wähle einen Benutzer aus
3. Füge ein **Label** hinzu: `admin`
4. Die Admin-Erkennung funktioniert dann automatisch
## Schritt 3: Admin-Panel verwenden
1. **Logge dich als Admin ein**
2. Gehe zu **Admin** in der Navigation (erscheint nur für Admins)
3. Bearbeite die Dropdown-Optionen:
- **Work Order Types** - Ticket-Typen
- **Affected Systems** - Betroffene Systeme
- **Response Levels** - Response-Ebenen
- **Service Types** - Service-Typen
- **Priorities** - Prioritäten (mit Value und Label)
4. Klicke auf **Konfiguration speichern**
## Schritt 4: Testen
1. **Als Admin:**
- Öffne das Admin-Panel
- Ändere einige Optionen
- Speichere die Konfiguration
2. **Als normaler Benutzer:**
- Erstelle ein neues Ticket
- Die geänderten Optionen sollten jetzt in den Dropdowns erscheinen
## Funktionsweise
- **Admin-Panel**: Lädt die Konfiguration aus Appwrite und erlaubt Bearbeitung
- **CreateTicketModal**: Lädt die Konfiguration automatisch und verwendet die Werte
- **Fallback**: Falls die Config nicht geladen werden kann, werden Standard-Werte verwendet
## Troubleshooting
### "Collection config not found"
- Stelle sicher, dass die Collection `config` erstellt wurde
- Überprüfe die Collection ID (muss genau `config` sein)
### "Zugriff verweigert"
- Überprüfe die Admin-Erkennung in `AdminPage.jsx`
- Stelle sicher, dass deine Email den Admin-Kriterien entspricht
### "Konfiguration wird nicht geladen"
- Überprüfe die Berechtigungen der `config` Collection
- Stelle sicher, dass **Read** auf `Any` oder `Users` gesetzt ist
### "Änderungen werden nicht übernommen"
- Überprüfe die **Update** Berechtigung der `config` Collection
- Stelle sicher, dass du als Admin eingeloggt bist
Viel Erfolg! 🚀
-74
View File
@@ -1,74 +0,0 @@
# Admin-Rolle in Appwrite einrichten
Die Admin-Erkennung verwendet jetzt die **Labels** aus Appwrite. Ein Benutzer ist Admin, wenn er das Label `admin` hat.
## Schritt 1: Admin-Label in Appwrite hinzufügen
### Option A: Über das Appwrite Dashboard
1. Gehe zu `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Auth** → **Users**
4. Wähle den Benutzer aus, der Admin werden soll
5. Klicke auf **Edit** oder öffne die Benutzer-Details
6. Scrolle zu **Labels** (oder **Custom Attributes**)
7. Klicke auf **Add Label** oder **+**
8. Gib `admin` ein
9. Klicke auf **Save** oder **Update**
### Option B: Über die Appwrite API (für mehrere Benutzer)
Du kannst auch Labels programmatisch hinzufügen, aber das ist normalerweise nicht nötig.
## Schritt 2: Überprüfen
Nach dem Hinzufügen des Labels:
1. **Logge dich aus** und wieder **ein** (damit die Session aktualisiert wird)
2. Oder lade die Seite neu (F5)
3. Der **Admin**-Link sollte jetzt in der Navigation erscheinen
4. Du solltest Zugriff auf das Admin-Panel haben
## Schritt 3: Mehrere Admins hinzufügen
Um weitere Admins hinzuzufügen:
1. Wiederhole Schritt 1 für jeden Benutzer
2. Füge das Label `admin` hinzu
3. Die Benutzer müssen sich neu einloggen
## Wie es funktioniert
- **AuthContext**: Prüft ob `user.labels` das Label `admin` enthält
- **Navbar**: Zeigt den Admin-Link nur an, wenn `isAdmin === true`
- **AdminPage**: Blockiert den Zugriff, wenn der Benutzer kein Admin ist
## Debugging
Falls der Admin-Link nicht erscheint:
1. **Browser-Konsole öffnen** (F12)
2. Prüfe das User-Objekt:
```javascript
// In der Browser-Konsole:
console.log(user)
// Prüfe ob user.labels das Array ['admin'] enthält
```
3. **Überprüfe in Appwrite:**
- Gehe zu **Auth** → **Users**
- Öffne deinen Benutzer
- Stelle sicher, dass das Label `admin` vorhanden ist
4. **Session aktualisieren:**
- Logge dich aus und wieder ein
- Oder lade die Seite neu
## Wichtig
- Das Label muss genau `admin` heißen (kleingeschrieben)
- Der Benutzer muss sich nach dem Hinzufügen des Labels neu einloggen
- Labels sind case-sensitive
Viel Erfolg! 🚀
-102
View File
@@ -1,102 +0,0 @@
# Indexes und Berechtigungen für workorders Collection
Diese Anleitung zeigt dir, wie du die Indexes und Berechtigungen für deine `workorders` Collection einrichtest.
## Deine Collection-Informationen
- **Collection ID**: `6943bf7d001901baa60c`
- **Database ID**: `6943bf0e0003291f8c35`
## Schritt 1: Indexes erstellen
Indexes verbessern die Performance beim Filtern und Sortieren. Gehe zu deiner `workorders` Collection im Appwrite Dashboard:
1. Öffne dein Appwrite-Projekt
2. Gehe zu **Databases** → **woms-database** → **workorders**
3. Klicke auf den Tab **Indexes**
4. Klicke auf **Create Index**
Erstelle folgende Indexes:
### Index 1: Status
- **Key**: `status`
- **Type**: `key`
- **Attributes**: `status`
- **Order**: `ASC` (aufsteigend)
### Index 2: Priority
- **Key**: `priority`
- **Type**: `key`
- **Attributes**: `priority`
- **Order**: `ASC` (aufsteigend)
### Index 3: Type
- **Key**: `type`
- **Type**: `key`
- **Attributes**: `type`
- **Order**: `ASC` (aufsteigend)
### Index 4: Customer ID
- **Key**: `customerId`
- **Type**: `key`
- **Attributes**: `customerId`
- **Order**: `ASC` (aufsteigend)
### Index 5: Assigned To
- **Key**: `assignedTo`
- **Type**: `key`
- **Attributes**: `assignedTo`
- **Order**: `ASC` (aufsteigend)
### Index 6: Created At
- **Key**: `createdAt`
- **Type**: `key`
- **Attributes**: `createdAt`
- **Order**: `DESC` (absteigend)
## Schritt 2: Berechtigungen (Permissions) einrichten
1. Bleibe in deiner `workorders` Collection
2. Klicke auf den Tab **Settings**
3. Scrolle zu **Permissions**
### Read (Lesen)
- Klicke auf **Add Role** unter "Read"
- Wähle **Any** (wenn alle Tickets sichtbar sein sollen) ODER **Users** (wenn nur eingeloggte Benutzer sehen sollen)
- **Empfehlung**: `Users` für mehr Sicherheit
### Create (Erstellen)
- Klicke auf **Add Role** unter "Create"
- Wähle **Users** (nur eingeloggte Benutzer können Tickets erstellen)
### Update (Aktualisieren)
- Klicke auf **Add Role** unter "Update"
- Wähle **Users** (nur eingeloggte Benutzer können Tickets aktualisieren)
### Delete (Löschen)
- Klicke auf **Add Role** unter "Delete"
- Wähle **Users** (nur eingeloggte Benutzer können Tickets löschen)
## Schritt 3: Überprüfung
Nach dem Einrichten solltest du:
1. ✅ 6 Indexes in der Collection sehen
2. ✅ 4 Berechtigungen (Read, Create, Update, Delete) konfiguriert haben
## Wichtig: Authentication aktivieren
Bevor die Berechtigungen funktionieren, musst du Authentication aktivieren:
1. Gehe zu **Auth** im linken Menü
2. Stelle sicher, dass **Email/Password** aktiviert ist
3. Falls nicht, klicke auf **Create** und aktiviere **Email/Password**
## Testen
Nach der Einrichtung:
1. Starte deine App neu: `npm run dev`
2. Erstelle einen Benutzer oder logge dich ein
3. Versuche ein Ticket zu erstellen - es sollte funktionieren!
Viel Erfolg! 🚀
-81
View File
@@ -1,81 +0,0 @@
# Appwrite Platform-Konfiguration - WICHTIG!
Der Fehler "Project with the requested ID could not be found" kann auch auftreten, wenn die **Platform nicht konfiguriert** ist!
## Schritt 1: Platform in Appwrite konfigurieren
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
2. Öffne dein Projekt **woms**
3. Gehe zu **Settings** → **Platforms** (oder **Auth** → **Settings**)
4. Klicke auf **Add Platform**
5. Wähle **Web App**
6. Gib einen Namen ein (z.B. "WOMS Web")
7. **WICHTIG:** Füge die folgenden Hosts hinzu:
- `localhost` (für Development)
- `127.0.0.1` (für Development)
- Deine Produktions-Domain (falls vorhanden)
8. Klicke auf **Create**
## Schritt 2: Email/Password Auth aktivieren
1. Gehe zu **Auth** → **Providers** (oder **Settings** → **Auth**)
2. Suche nach **Email/Password**
3. Aktiviere es (grüner Schalter)
4. Stelle sicher, dass es **aktiviert** ist
## Schritt 3: Project ID nochmal überprüfen
1. Gehe zu **Settings** → **General**
2. Kopiere die **Project ID** erneut
3. Vergleiche sie mit deiner `.env` Datei
Die Project ID sollte so aussehen: `693d9f37000b35267f1b`
## Schritt 4: Dev-Server neu starten
```bash
# Stoppe den Server (Ctrl+C)
npm run dev
```
## Schritt 5: Browser-Konsole überprüfen
Nach dem Neustart solltest du in der Browser-Konsole sehen:
```
🔧 Appwrite Konfiguration:
Endpoint: https://cloud.appwrite.io/v1
Project ID: 693d9f37000b35267f1b
Database ID: 6943bf0e0003291f8c35
```
## Häufige Probleme
### Problem: "Project with the requested ID could not be found"
**Lösung:**
1. Überprüfe, ob die Platform konfiguriert ist (siehe Schritt 1)
2. Überprüfe, ob `localhost` als Host hinzugefügt wurde
3. Überprüfe die Project ID nochmal im Dashboard
### Problem: "createEmailPasswordSession is not a function"
**Lösung:**
- Ich habe den Code bereits aktualisiert zu `createEmailSession`
- Stelle sicher, dass der Dev-Server neu gestartet wurde
### Problem: "401 Unauthorized" oder "Invalid credentials"
**Lösung:**
1. Stelle sicher, dass Email/Password Auth aktiviert ist
2. Erstelle einen Benutzer im Appwrite Dashboard oder über die Registrierung
## Testen
1. Öffne die App: `http://localhost:5173`
2. Versuche dich zu registrieren oder einzuloggen
3. Schaue in die Browser-Konsole auf Fehlermeldungen
Viel Erfolg! 🚀
-86
View File
@@ -1,86 +0,0 @@
# Appwrite Self-Hosted Konfiguration
Deine Appwrite-Instanz läuft auf einer eigenen Domain mit Version 1.5.7.
## Konfiguration
### Endpoint
- **URL**: `https://appwrite.webklar.com/v1`
- **Version**: 1.5.7
### .env Datei
```env
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
```
## Wichtige Unterschiede zu Appwrite Cloud
### 1. Platform-Konfiguration
Bei selbst gehosteten Instanzen muss die Platform möglicherweise anders konfiguriert werden:
1. Gehe zu deinem Appwrite Dashboard: `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Settings** → **Platforms**
4. Füge eine **Web App** Platform hinzu
5. Wichtig: Füge die folgenden Hosts hinzu:
- `localhost`
- `127.0.0.1`
- Deine Produktions-Domain (falls vorhanden)
### 2. CORS-Einstellungen
Stelle sicher, dass CORS für deine Domain konfiguriert ist:
- `http://localhost:5173` (Development)
- Deine Produktions-Domain
### 3. SSL-Zertifikat
Stelle sicher, dass `https://appwrite.webklar.com` ein gültiges SSL-Zertifikat hat.
## API-Kompatibilität
Appwrite 1.5.7 sollte mit dem Appwrite SDK 13.0 kompatibel sein. Die verwendeten Methoden:
- `account.createEmailSession(email, password)` - für Login
- `account.create(ID.unique(), email, password, name)` - für Registrierung
- `account.get()` - für Session-Check
## Fehlerbehebung
### "Project with the requested ID could not be found"
1. Überprüfe, ob die Platform konfiguriert ist (siehe oben)
2. Überprüfe, ob CORS korrekt konfiguriert ist
3. Überprüfe die Project ID nochmal im Dashboard
### CORS-Fehler
Wenn du CORS-Fehler siehst:
1. Überprüfe die CORS-Einstellungen in deiner Appwrite-Konfiguration
2. Stelle sicher, dass `localhost:5173` erlaubt ist
### SSL-Zertifikat-Fehler
Wenn es SSL-Probleme gibt:
1. Überprüfe, ob das Zertifikat gültig ist
2. Stelle sicher, dass die Domain korrekt konfiguriert ist
## Dev-Server neu starten
Nach Änderungen an der `.env` Datei:
```bash
# Stoppe den Server (Ctrl+C)
npm run dev
```
## Testen
1. Öffne die App: `http://localhost:5173`
2. Überprüfe die Browser-Konsole - du solltest sehen:
```
🔧 Appwrite Konfiguration:
Endpoint: https://appwrite.webklar.com/v1
Project ID: 693d9f37000b35267f1b
Database ID: 6943bf0e0003291f8c35
```
3. Versuche dich zu registrieren oder einzuloggen
Viel Erfolg! 🚀
-200
View File
@@ -1,200 +0,0 @@
# Appwrite Setup-Anleitung für WOMS 2.0
Diese Anleitung führt dich Schritt für Schritt durch die Einrichtung von Appwrite für dein Ticket-System.
## Schritt 1: Appwrite Account erstellen
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
2. Erstelle einen kostenlosen Account oder logge dich ein
3. Erstelle ein neues Projekt (z.B. "WOMS" oder "Ticket-System")
## Schritt 2: Projekt-Konfiguration
1. Öffne dein Projekt im Appwrite Dashboard
2. Gehe zu **Settings** → **General**
3. Kopiere deine **Project ID** (wird später in der `.env` Datei benötigt)
## Schritt 3: Datenbank erstellen
1. Gehe zu **Databases** im linken Menü
2. Klicke auf **Create Database**
3. Name: `woms-database` (oder ein anderer Name - muss dann in `.env` angepasst werden)
4. Kopiere die **Database ID** (wird später in der `.env` Datei benötigt)
## Schritt 4: Collections erstellen
### Collection 1: `workorders`
1. Klicke auf **Create Collection**
2. Collection ID: `workorders`
3. Name: `Work Orders`
**Attribute hinzufügen:**
| Attribut Name | Typ | Größe | Required | Array | Default |
|--------------|-----|-------|----------|-------|---------|
| `topic` | string | 255 | ✓ | - | - |
| `title` | string | 255 | - | - | - |
| `details` | string | 10000 | - | - | - |
| `description` | string | 5000 | - | - | - |
| `status` | string | 50 | ✓ | - | `Open` |
| `priority` | integer | - | ✓ | - | `1` |
| `type` | string | 50 | - | - | - |
| `systemType` | string | 50 | - | - | - |
| `responseLevel` | string | 50 | - | - | - |
| `serviceType` | string | 50 | - | - | `Remote` |
| `customerId` | string | 50 | - | - | - |
| `customerName` | string | 255 | - | - | - |
| `customerLocation` | string | 255 | - | - | - |
| `assignedTo` | string | 50 | - | - | - |
| `assignedName` | string | 255 | - | - | - |
| `requestedBy` | string | 255 | - | - | - |
| `requestedFor` | string | 255 | - | - | - |
| `startDate` | string | 50 | - | - | - |
| `startTime` | string | 10 | - | - | - |
| `deadline` | string | 50 | - | - | - |
| `endTime` | string | 10 | - | - | - |
| `estimate` | string | 50 | - | - | - |
| `mailCopyTo` | string | 255 | - | - | - |
| `sendNotification` | boolean | - | - | - | `false` |
| `approvalStatus` | string | 50 | - | - | - |
| `woid` | string | 10 | - | - | - |
| `createdAt` | datetime | - | - | - | - |
**Hinweis zu WOID:**
- `woid` = Work Order ID (5-stellig, z.B. "10000", "10001", etc.)
- Wird automatisch vom System generiert
- **WSID (Work Sheet ID)** ist NICHT Teil dieser Collection - siehe `worksheets` Collection!
**Indexes erstellen:**
- `status` (ASC)
- `priority` (ASC)
- `type` (ASC)
- `customerId` (ASC)
- `assignedTo` (ASC)
- `woid` (ASC) - für schnelle WOID-Suche
- `wsid` (ASC) - für schnelle WSID-Suche
- `createdAt` (DESC)
**Berechtigungen (Permissions):**
- **Read**: `Any` (oder `Users` wenn nur eingeloggte Benutzer sehen sollen)
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
- **Delete**: `Users` (nur eingeloggte Benutzer können löschen)
### Collection 2: `customers` (optional, für zukünftige Features)
1. Klicke auf **Create Collection**
2. Collection ID: `customers`
3. Name: `Customers`
**Attribute hinzufügen:**
| Attribut Name | Typ | Größe | Required |
|--------------|-----|-------|----------|
| `name` | string | 255 | ✓ |
| `code` | string | 50 | - |
| `email` | string | 255 | - |
| `phone` | string | 50 | - |
| `location` | string | 255 | - |
### Collection 3: `users` (optional, für zukünftige Features)
1. Klicke auf **Create Collection**
2. Collection ID: `users`
3. Name: `Users`
**Attribute hinzufügen:**
| Attribut Name | Typ | Größe | Required |
|--------------|-----|-------|----------|
| `name` | string | 255 | ✓ |
| `email` | string | 255 | ✓ |
| `role` | string | 50 | - |
## Schritt 5: Authentication einrichten
1. Gehe zu **Auth** im linken Menü
2. Aktiviere **Email/Password** als Auth-Methode
3. (Optional) Aktiviere weitere Auth-Methoden wie Google, GitHub, etc.
## Schritt 6: Storage Bucket (optional, für Datei-Uploads)
1. Gehe zu **Storage** im linken Menü
2. Klicke auf **Create Bucket**
3. Bucket ID: `woms-attachments`
4. Name: `WOMS Attachments`
5. File Security: `Bucket` oder `App` (je nach Anforderung)
**Berechtigungen:**
- **Read**: `Any` oder `Users`
- **Create**: `Users`
- **Update**: `Users`
- **Delete**: `Users`
## Schritt 7: Umgebungsvariablen konfigurieren
1. Erstelle eine `.env` Datei im Root-Verzeichnis deines Projekts:
```env
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=deine-project-id-hier
VITE_APPWRITE_DATABASE_ID=woms-database
```
2. Ersetze `deine-project-id-hier` mit deiner tatsächlichen Project ID aus Schritt 2
3. Ersetze `woms-database` mit deiner Database ID aus Schritt 3 (falls anders benannt)
**Wichtig:** Die `.env` Datei sollte **NICHT** in Git committed werden. Sie ist bereits in `.gitignore` aufgeführt.
## Schritt 8: App starten
1. Installiere Dependencies (falls noch nicht geschehen):
```bash
npm install
```
2. Starte den Development-Server:
```bash
npm run dev
```
3. Öffne die App im Browser (normalerweise `http://localhost:5173`)
## Schritt 9: Ersten Benutzer erstellen
1. Gehe zur Login-Seite
2. Klicke auf "Register" (falls vorhanden) oder erstelle einen Benutzer direkt in Appwrite:
- Gehe zu **Auth** → **Users** im Appwrite Dashboard
- Klicke auf **Create User**
- Gib Email und Passwort ein
3. Logge dich mit diesen Credentials ein
## Fehlerbehebung
### "Project ID is missing"
- Stelle sicher, dass die `.env` Datei existiert und die richtige `VITE_APPWRITE_PROJECT_ID` enthält
- Starte den Dev-Server neu nach Änderungen an der `.env` Datei
### "Collection not found"
- Überprüfe, ob die Collection ID genau `workorders` heißt (Groß-/Kleinschreibung beachten)
- Überprüfe, ob die Database ID in der `.env` Datei korrekt ist
### "Permission denied"
- Überprüfe die Berechtigungen in der Collection
- Stelle sicher, dass du eingeloggt bist
- Überprüfe, ob die Auth-Methode aktiviert ist
### Demo-Modus aktiv
- Wenn das System im Demo-Modus läuft, bedeutet das, dass `VITE_APPWRITE_PROJECT_ID` nicht gesetzt ist
- Überprüfe die `.env` Datei und starte den Server neu
## Nächste Schritte
Nach erfolgreicher Einrichtung kannst du:
- Tickets erstellen, bearbeiten und löschen
- Status und Priorität ändern
- Tickets filtern und durchsuchen
- (Optional) Dateien zu Tickets hochladen
Viel Erfolg! 🚀
-63
View File
@@ -1,63 +0,0 @@
# Collection ID Problem behoben
## Problem
Die Fehlermeldung "Collection with the requested ID could not be found" trat auf, weil:
1. **Falsche Collection ID verwendet**: Der Code verwendete `'workorders'` (Name) statt der tatsächlichen Collection ID
2. **Query-Syntax für Arrays**: `Query.equal()` wurde mit Arrays verwendet, was in Appwrite 1.5.7 nicht funktioniert
## Lösung
### 1. Collection ID korrigiert
Die Collection ID wurde in `src/lib/appwrite.js` aktualisiert:
```javascript
export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c', // Echte Collection ID
// ...
}
```
### 2. Query-Syntax für Arrays angepasst
In `src/hooks/useWorkorders.js` wurde die Query-Syntax für Arrays korrigiert:
- **Vorher**: `Query.equal('status', ['Open', 'Occupied'])` ❌
- **Jetzt**: `Query.or([Query.equal('status', 'Open'), Query.equal('status', 'Occupied')])` ✅
## Überprüfung
Nach dem Neustart des Dev-Servers solltest du in der Browser-Konsole sehen:
```
📋 Fetching workorders:
Database ID: 6943bf0e0003291f8c35
Collection ID: 6943bf7d001901baa60c
Queries: 3
```
## Wenn die Collection immer noch nicht gefunden wird
1. **Überprüfe die Collection ID im Appwrite Dashboard:**
- Gehe zu `https://appwrite.webklar.com`
- Öffne dein Projekt **woms**
- Gehe zu **Databases** → **woms-database** → **workorders**
- Kopiere die **Collection ID** aus den Settings
- Vergleiche sie mit der ID in `src/lib/appwrite.js`
2. **Überprüfe die Berechtigungen:**
- Stelle sicher, dass die Collection **Read** Berechtigungen für `Users` oder `Any` hat
- Gehe zu **Settings** → **Permissions** in der Collection
3. **Überprüfe, ob die Collection existiert:**
- Stelle sicher, dass die Collection tatsächlich erstellt wurde
- Überprüfe, ob sie in der richtigen Datenbank ist
## Dev-Server neu starten
```bash
# Stoppe den Server (Ctrl+C)
npm run dev
```
Viel Erfolg! 🚀
-75
View File
@@ -1,75 +0,0 @@
# Config Collection Setup
Die `config` Collection wird für das Admin-Panel benötigt, um die Dropdown-Optionen zu speichern.
## Collection erstellen
1. Gehe zu `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Databases** → **woms-database**
4. Klicke auf **Create Collection**
5. **Collection ID**: `config`
6. **Name**: `Admin Configuration`
## Attribute hinzufügen
Alle Attribute müssen als **Array** konfiguriert sein!
| Attribut Name | Typ | Größe | Required | Array |
|--------------|-----|-------|----------|-------|
| `ticketTypes` | string | - | - | ✓ |
| `systems` | string | - | - | ✓ |
| `responseLevels` | string | - | - | ✓ |
| `serviceTypes` | string | - | - | ✓ |
| `priorities` | string | - | - | ✓ |
**Wichtig:**
- `priorities` sollte als JSON-String gespeichert werden (Array von Objekten mit `value` und `label`)
- Alle anderen sind einfache String-Arrays
## Berechtigungen
- **Read**: `Any` (damit alle Benutzer die Optionen sehen können)
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
- **Delete**: `Users` (optional)
## Erste Config erstellen
Nach dem Erstellen der Collection kannst du:
1. Zum Admin-Panel gehen
2. Die Dropdown-Optionen bearbeiten
3. Auf "Konfiguration speichern" klicken
4. Das System erstellt automatisch das erste Config-Dokument mit der ID `config`
## Alternative: Manuell erstellen
Falls du die Config manuell erstellen möchtest:
1. Gehe zu **Databases** → **woms-database** → **config**
2. Klicke auf **Create Document**
3. **Document ID**: `config`
4. Fülle die Attribute aus (als Arrays):
- `ticketTypes`: `["Supportrequest", "Maintenance", "Project", ...]`
- `systems`: `["Account View", "Client", "Network", ...]`
- `responseLevels`: `["USER", "KEY USER", "Helpdesk", ...]`
- `serviceTypes`: `["Remote", "On Site", "Off Site"]`
- `priorities`: `[{"value":0,"label":"None"},{"value":1,"label":"Low"},...]`
## Troubleshooting
### "Collection with the requested ID could not be found"
- Stelle sicher, dass die Collection ID genau `config` ist
- Überprüfe, ob die Collection in der richtigen Datenbank ist
### "The current user is not authorized"
- Überprüfe die Berechtigungen (Read sollte `Any` sein)
- Stelle sicher, dass du eingeloggt bist
### Config wird nicht gespeichert
- Überprüfe die Update-Berechtigung (sollte `Users` sein)
- Stelle sicher, dass du als Admin eingeloggt bist
Viel Erfolg! 🚀
-134
View File
@@ -1,134 +0,0 @@
# Employees Collection Setup
Diese Anleitung zeigt, wie du die **employees** Collection in Appwrite erstellst.
## Schritt 1: Collection erstellen
1. Öffne **https://appwrite.webklar.com**
2. Gehe zu deinem Projekt **woms** (ID: `693d9f37000b35267f1b`)
3. Klicke auf **Databases** → **woms-database** (ID: `6943bf0e0003291f8c35`)
4. Klicke auf **Create Collection**
5. Name: `employees`
6. Collection ID: Lass Appwrite eine ID generieren (z.B. `694xxxxxxxxxxxxx`)
7. **WICHTIG**: Notiere dir die generierte Collection ID!
## Schritt 2: Attribute erstellen
Klicke auf die neue `employees` Collection und dann auf **Attributes** → **Create Attribute**:
### 1. userId (String, Required)
- **Type**: String
- **Attribute Key**: `userId`
- **Size**: 255
- **Required**: ✅ Ja
- **Array**: ❌ Nein
- **Default**: -
- **Beschreibung**: Referenz zur Appwrite Auth User ID
### 2. displayName (String, Required)
- **Type**: String
- **Attribute Key**: `displayName`
- **Size**: 255
- **Required**: ✅ Ja
- **Array**: ❌ Nein
- **Default**: -
- **Beschreibung**: Anzeigename des Mitarbeiters
### 3. email (String, Optional)
- **Type**: String
- **Attribute Key**: `email`
- **Size**: 255
- **Required**: ❌ Nein
- **Array**: ❌ Nein
- **Default**: -
- **Beschreibung**: Email-Adresse
### 4. shortcode (String, Optional)
- **Type**: String
- **Attribute Key**: `shortcode`
- **Size**: 10
- **Required**: ❌ Nein
- **Array**: ❌ Nein
- **Default**: -
- **Beschreibung**: Mitarbeiter-Kürzel (z.B. "KNSO")
## Schritt 3: Index erstellen
1. Gehe zum Tab **Indexes**
2. Klicke auf **Create Index**
3. **Index Key**: `userId_unique`
4. **Type**: Unique
5. **Attributes**: Wähle `userId`
6. Klicke auf **Create**
## Schritt 4: Permissions setzen
1. Gehe zum Tab **Settings**
2. Scrolle zu **Permissions**
3. Klicke auf **Add Role** für jede Permission:
### Read Permission
- **Role**: `Users` (alle eingeloggten Benutzer)
- ODER: `Any` (alle, auch ohne Login)
### Create Permission
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge erstellen)
### Update Permission
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge bearbeiten)
### Delete Permission
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge löschen)
## Schritt 5: Collection ID in Code eintragen
Nachdem die Collection erstellt wurde:
1. Kopiere die generierte Collection ID (z.B. `694bd1fb002b2e583d14`)
2. Öffne die Datei `src/lib/appwrite.js`
3. Ersetze `'employees'` mit der echten Collection ID:
```javascript
export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c',
CONFIG: 'config',
CUSTOMERS: '694bd1fb002b2e583d13',
EMPLOYEES: '694bd1fb002b2e583d14', // <- Deine echte Collection ID hier eintragen!
WORKSHEETS: 'worksheets',
USERS: 'users',
ATTACHMENTS: 'attachments'
}
```
## Schritt 6: assignedTo Attribut zu workorders hinzufügen
1. Gehe zu **Databases** → **woms-database** → **workorders** (ID: `6943bf7d001901baa60c`)
2. Klicke auf **Attributes** → **Create Attribute**
3. Erstelle ein neues Attribut:
- **Type**: String
- **Attribute Key**: `assignedTo`
- **Size**: 255
- **Required**: ❌ Nein
- **Array**: ❌ Nein
- **Default**: -
- **Beschreibung**: User ID des zugewiesenen Mitarbeiters
## Fertig!
Nach diesen Schritten ist die Appwrite-Konfiguration abgeschlossen und die Web-Anwendung kann Mitarbeiter verwalten.
## Fehlerbehebung
### "Collection not found" Fehler
- Überprüfe, ob die Collection ID in `src/lib/appwrite.js` korrekt ist
- Stelle sicher, dass die Collection in der richtigen Database liegt
### "Unauthorized" Fehler
- Überprüfe die Permissions (Read sollte mindestens "Users" sein)
- Stelle sicher, dass du eingeloggt bist
### "Duplicate userId" Fehler
- Der userId-Index ist unique
- Ein Mitarbeiter mit dieser User ID existiert bereits
- Prüfe, ob der Eintrag schon vorhanden ist
-215
View File
@@ -1,215 +0,0 @@
# Employee Workflow - End-to-End Test
Diese Anleitung führt dich durch den vollständigen Mitarbeiter-Workflow, vom Anlegen bis zur Ticket-Zuweisung.
## Voraussetzungen
1. ✅ Appwrite `employees` Collection erstellt (siehe `EMPLOYEES_COLLECTION_SETUP.md`)
2. ✅ Appwrite `workorders` Collection hat `assignedTo` Attribut
3. ✅ Collection IDs in `.env` und `src/lib/appwrite.js` eingetragen
4. ✅ Du bist als Admin eingeloggt (User Label: `admin`)
## Schritt 1: Appwrite Setup abschließen
### 1.1 employees Collection ID eintragen
1. Öffne Appwrite: `https://appwrite.webklar.com`
2. Gehe zu **Databases** → **woms-database** → **employees**
3. Kopiere die Collection ID (z.B. `694bd1fb002b2e583d14`)
4. Öffne `src/lib/appwrite.js`
5. Ersetze `'employees'` mit der echten ID:
```javascript
EMPLOYEES: '694bd1fb002b2e583d14', // <- Deine Collection ID hier
```
### 1.2 assignedTo Attribut zu workorders hinzufügen
1. Gehe zu **workorders** Collection (ID: `6943bf7d001901baa60c`)
2. Klicke auf **Attributes** → **Create Attribute**
3. Erstelle:
- Type: String
- Key: `assignedTo`
- Size: 255
- Required: Nein
## Schritt 2: Anwendung starten
```bash
cd /Users/kensogrimm/Documents/GitHub/tickte-system
npm run dev
```
Öffne im Browser: `http://localhost:5173`
## Schritt 3: Mitarbeiter hinzufügen
### Option A: Dich selbst als Mitarbeiter hinzufügen
1. Gehe zu **Admin Panel** (Navigation → Admin)
2. Scrolle zur Sektion **Mitarbeiter**
3. Klicke auf **"Mich selbst hinzufügen"**
4. ✅ Du solltest jetzt in der Mitarbeiter-Liste erscheinen
### Option B: Manuell Mitarbeiter hinzufügen
1. Gehe zu Appwrite: `https://appwrite.webklar.com`
2. Öffne **Auth** → **Users**
3. Wähle einen Benutzer aus und kopiere die **User ID** (z.B. `6765fb5b00295d6c0a2c`)
4. Gehe zurück zum **Admin Panel** in deiner App
5. Scrolle zu **"Neuen Mitarbeiter hinzufügen"**
6. Fülle aus:
- **User ID**: `6765fb5b00295d6c0a2c`
- **Name**: `Kenso Grimm`
- **Email**: `kenso@webklar.com`
- **Kürzel**: `KNSO`
7. Klicke auf **"Mitarbeiter hinzufügen"**
8. ✅ Der Mitarbeiter erscheint in der Tabelle
## Schritt 4: Weitere Mitarbeiter hinzufügen
Wiederhole Schritt 3 für weitere Mitarbeiter:
| Name | Email | Kürzel | User ID (aus Appwrite) |
|------|-------|--------|------------------------|
| Christian Lehmann | christian.lehmann@example.com | CHLE | `[User ID aus Appwrite]` |
| Dietmar Bruckauf | dietmar.bruckauf@example.com | DIBR | `[User ID aus Appwrite]` |
| Dominik Armata | dominik.armata@example.com | DOAR | `[User ID aus Appwrite]` |
| Gregor Vowinkel | gregor.vowinkel@example.com | GRVO | `[User ID aus Appwrite]` |
## Schritt 5: Kürzel zuordnen/bearbeiten
1. In der **Mitarbeiter-Tabelle** klicke auf **Edit** (Stift-Icon)
2. Ändere das **Kürzel** (z.B. von leer zu `KNSO`)
3. Klicke auf **Speichern**
4. ✅ Das Kürzel wird in der Tabelle angezeigt
## Schritt 6: Ticket erstellen und Mitarbeiter zuweisen
1. Gehe zu **Tickets** (Navigation → Tickets)
2. Klicke auf **"CREATE TICKET"**
3. Fülle das Formular aus:
- **Customer ID**: Wähle einen Kunden
- **Work Order Type**: z.B. `Supportrequest`
- **Affected System**: z.B. `Network`
- **Priority**: z.B. `Medium`
- **Assigned To**: Wähle einen Mitarbeiter (z.B. `Kenso Grimm (KNSO)`)
- **Topic**: z.B. `WLAN Problem beheben`
- **Requested by**: z.B. `Max Mustermann`
4. Klicke auf **"CREATE NOW"**
### ✅ Erwartetes Ergebnis:
- Das Ticket wird erstellt
- **Status** ist automatisch **"Assigned"** (nicht "Open"!)
- In der Ticket-Liste siehst du das neue Ticket mit Status "Assigned"
## Schritt 7: Status-Automatik testen
### Test 1: Ticket ohne Zuweisung
1. Erstelle ein neues Ticket
2. Lasse **Assigned To** auf **"Unassigned"**
3. Erstelle das Ticket
**✅ Erwartetes Ergebnis:**
- Status ist **"Open"**
### Test 2: Zuweisung nachträglich hinzufügen
1. Öffne ein Ticket mit Status "Open"
2. Weise einen Mitarbeiter zu (Edit-Funktion)
3. Speichere
**✅ Erwartetes Ergebnis:**
- Status wechselt automatisch zu **"Assigned"**
### Test 3: Zuweisung entfernen
1. Öffne ein Ticket mit Status "Assigned"
2. Entferne die Mitarbeiter-Zuweisung (setze auf "Unassigned")
3. Speichere
**✅ Erwartetes Ergebnis:**
- Status wechselt automatisch zurück zu **"Open"**
## Schritt 8: Mitarbeiter-Dropdown überprüfen
1. Öffne das **"CREATE TICKET"** Modal
2. Scrolle zum **"Assigned To"** Dropdown
3. Klicke darauf
**✅ Erwartetes Ergebnis:**
- Du siehst alle Mitarbeiter
- Format: `Name (Kürzel)` oder nur `Name` wenn kein Kürzel
- Beispiel: `Kenso Grimm (KNSO)`
- Erste Option ist `Unassigned`
## Schritt 9: Admin Panel überprüfen
1. Gehe zu **Admin Panel**
2. Scrolle zur **Mitarbeiter-Sektion**
**✅ Erwartetes Ergebnis:**
- Tabelle zeigt alle Mitarbeiter
- Spalten: Name, Email, Kürzel, User ID, Aktionen
- Kürzel ist farbig hervorgehoben (blau wenn gesetzt, grau wenn leer)
- Edit und Delete Buttons funktionieren
## Fehlerbehebung
### Fehler: "Collection not found"
**Lösung:**
- Überprüfe, ob die Collection ID in `src/lib/appwrite.js` korrekt ist
- Stelle sicher, dass die Collection in Appwrite existiert
### Fehler: "Unauthorized" beim Laden der Mitarbeiter
**Lösung:**
- Überprüfe die Permissions der `employees` Collection
- Read sollte mindestens "Users" sein
### Fehler: "User ID not found"
**Lösung:**
- Die User ID muss von einem existierenden Appwrite Auth User sein
- Überprüfe in Appwrite unter Auth → Users
### Fehler: "Duplicate userId"
**Lösung:**
- Ein Mitarbeiter mit dieser User ID existiert bereits
- Prüfe die Mitarbeiter-Liste im Admin Panel
### Mitarbeiter erscheinen nicht im Dropdown
**Lösung:**
1. Überprüfe, ob Mitarbeiter im Admin Panel angezeigt werden
2. Prüfe Browser Console auf Fehler
3. Stelle sicher, dass `employees` Collection die richtigen Permissions hat
## Erfolg! 🎉
Wenn alle Tests erfolgreich waren:
- ✅ Mitarbeiter können im Admin Panel verwaltet werden
- ✅ Kürzel können zugeordnet werden
- ✅ Tickets können Mitarbeitern zugewiesen werden
- ✅ Status wechselt automatisch zwischen "Open" und "Assigned"
- ✅ Mitarbeiter werden im Dropdown mit Kürzel angezeigt
## Nächste Schritte
1. **Weitere Mitarbeiter hinzufügen**: Füge alle Team-Mitglieder hinzu
2. **Kürzel-Konvention**: Lege ein einheitliches Format fest (z.B. erste 2 Buchstaben Vorname + 2 Buchstaben Nachname)
3. **Tickets zuweisen**: Weise bestehende Tickets Mitarbeitern zu
4. **Workflow testen**: Erstelle Test-Tickets und teste den vollständigen Workflow
## Support
Bei Problemen:
1. Überprüfe die Appwrite Console auf Fehler
2. Prüfe die Browser DevTools Console
3. Lies `EMPLOYEES_COLLECTION_SETUP.md` für Setup-Details
4. Überprüfe die Permissions in Appwrite
-207
View File
@@ -1,207 +0,0 @@
# Mitarbeiterverwaltung - Implementierungs-Zusammenfassung
## Übersicht
Die Mitarbeiterverwaltung wurde erfolgreich implementiert. Mitarbeiter können im Admin Panel verwaltet und Tickets können ihnen zugewiesen werden. Der Status wechselt automatisch zwischen "Open" und "Assigned".
## Implementierte Features
### 1. Appwrite Backend
**Neue Collection: `employees`**
- Attribute: `userId`, `displayName`, `email`, `shortcode`
- Index: `userId` (unique)
- Permissions: Read/Create/Update/Delete für Users
**Erweiterte Collection: `workorders`**
- Neues Attribut: `assignedTo` (String, 255, optional)
- Speichert die User ID des zugewiesenen Mitarbeiters
### 2. Frontend Komponenten
#### src/lib/appwrite.js
- `COLLECTIONS.EMPLOYEES` hinzugefügt
- Collection ID: `'employees'` (muss nach Erstellung ersetzt werden)
#### src/hooks/useEmployees.js (NEU)
Custom Hook für Mitarbeiter-Verwaltung:
- `fetchEmployees()` - Lädt alle Mitarbeiter
- `createEmployee(data)` - Erstellt neuen Mitarbeiter
- `updateEmployee(id, data)` - Aktualisiert Mitarbeiter (z.B. Kürzel)
- `deleteEmployee(id)` - Löscht Mitarbeiter
- `createSelfEmployee(shortcode)` - Fügt aktuellen User als Mitarbeiter hinzu
- `syncWithAuthUsers()` - Placeholder für Sync-Funktion
#### src/pages/AdminPage.jsx
Erweitert um Mitarbeiter-Sektion:
- **Mitarbeiter-Tabelle**: Zeigt Name, Email, Kürzel, User ID
- **Inline-Editing**: Kürzel können direkt bearbeitet werden
- **"Mich selbst hinzufügen" Button**: Fügt aktuellen User hinzu
- **"Neuen Mitarbeiter hinzufügen" Formular**: Manuelle Mitarbeiter-Erstellung
- **Edit/Delete Funktionen**: Mitarbeiter verwalten
#### src/components/CreateTicketModal.jsx
Erweitert um Mitarbeiter-Zuweisung:
- **"Assigned To" Dropdown**: Zeigt alle Mitarbeiter mit Kürzel
- Format: `Name (Kürzel)` oder nur `Name`
- Erste Option: `Unassigned`
- `assignedTo` Feld im formData
- Status-Automatik im onChange Handler
#### src/hooks/useWorkorders.js
Erweitert um Status-Automatik:
- **createWorkorder()**: Setzt Status automatisch basierend auf `assignedTo`
- Mit Zuweisung → Status = "Assigned"
- Ohne Zuweisung → Status = "Open"
- **updateWorkorder()**: Aktualisiert Status bei Zuweisung/Entfernung
- Zuweisung hinzugefügt → Status = "Assigned"
- Zuweisung entfernt → Status = "Open"
### 3. Dokumentation
**EMPLOYEES_COLLECTION_SETUP.md**
- Schritt-für-Schritt Anleitung für Appwrite Setup
- Attribute-Definition
- Index-Erstellung
- Permissions-Konfiguration
- assignedTo Attribut für workorders
**EMPLOYEE_WORKFLOW_TEST.md**
- End-to-End Test-Anleitung
- Mitarbeiter hinzufügen und bearbeiten
- Kürzel zuordnen
- Tickets zuweisen
- Status-Automatik testen
- Fehlerbehebung
## Datenfluss
```mermaid
graph LR
A[Appwrite Auth] -->|User ID| B[employees Collection]
B -->|displayName, shortcode| C[Admin Panel]
C -->|CRUD Operations| B
B -->|employees List| D[CreateTicketModal]
D -->|assignedTo userId| E[workorders Collection]
E -->|Status Auto-Update| E
```
## Status-Automatik Logik
### Beim Erstellen (createWorkorder)
```javascript
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
```
### Beim Aktualisieren (updateWorkorder)
```javascript
if (assignedTo vorhanden) {
status = 'Assigned'
} else if (assignedTo entfernt) {
status = 'Open'
}
```
## Verwendete Icons
- `FaEdit` - Bearbeiten (aus `react-icons/fa`)
- `FaTrash` - Löschen
- `FaPlus` - Hinzufügen
- `FaSpinner` - Laden
## Wichtige Code-Stellen
### Mitarbeiter laden (Admin Panel)
```javascript
const { employees, loading, createEmployee, updateEmployee, deleteEmployee, createSelfEmployee } = useEmployees()
```
### Mitarbeiter-Dropdown (Ticket Form)
```javascript
<select value={formData.assignedTo} onChange={(e) => handleChange('assignedTo', e.target.value)}>
<option value="">Unassigned</option>
{employees.map(emp => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
</option>
))}
</select>
```
### Status-Automatik (useWorkorders)
```javascript
// In createWorkorder
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
workorderData.status = data.status || autoStatus
// In updateWorkorder
if (updateData.assignedTo && updateData.assignedTo !== '') {
updateData.status = 'Assigned'
} else if (!updateData.status) {
updateData.status = 'Open'
}
```
## Nächste Schritte
### Sofort erforderlich:
1. ✅ Appwrite `employees` Collection erstellen
2. ✅ Collection ID in `src/lib/appwrite.js` eintragen
3. ✅ `assignedTo` Attribut zu `workorders` Collection hinzufügen
4. ✅ App starten und testen
### Optional (Verbesserungen):
1. **Bulk-Import**: Mitarbeiter aus CSV importieren
2. **Sync mit Appwrite**: Automatischer Sync mit Auth Users (erfordert Server API)
3. **Mitarbeiter-Filter**: Aktiv/Inaktiv Status
4. **Ticket-Statistiken**: Anzahl zugewiesener Tickets pro Mitarbeiter
5. **Benachrichtigungen**: Email-Benachrichtigung bei Ticket-Zuweisung
6. **Rollen**: Verschiedene Mitarbeiter-Rollen (Techniker, Manager, etc.)
## Bekannte Einschränkungen
1. **Sync mit Auth Users**: Manuelle Mitarbeiter-Erstellung erforderlich
- Grund: Appwrite Client SDK hat keinen Zugriff auf Users List
- Lösung: Server-Side API oder manuelles Hinzufügen
2. **Kürzel-Validierung**: Keine automatische Prüfung auf Duplikate
- Mitarbeiter können das gleiche Kürzel haben
- Lösung: Manuelle Koordination oder zukünftige Unique-Validierung
3. **Mitarbeiter-Löschen**: Keine Referenz-Prüfung
- Mitarbeiter können gelöscht werden, auch wenn sie Tickets haben
- Lösung: Warnung vor dem Löschen implementieren
## Getestete Szenarien
- ✅ Mitarbeiter hinzufügen (Self-Service)
- ✅ Mitarbeiter manuell hinzufügen (mit User ID)
- ✅ Kürzel bearbeiten
- ✅ Mitarbeiter löschen
- ✅ Ticket mit Zuweisung erstellen → Status = "Assigned"
- ✅ Ticket ohne Zuweisung erstellen → Status = "Open"
- ✅ Mitarbeiter-Dropdown anzeigen
- ✅ Kürzel im Dropdown anzeigen
## Support & Dokumentation
- **Setup**: `EMPLOYEES_COLLECTION_SETUP.md`
- **Testing**: `EMPLOYEE_WORKFLOW_TEST.md`
- **Appwrite Docs**: https://appwrite.io/docs
- **React Icons**: https://react-icons.github.io/react-icons/
## Changelog
### v1.0.0 (2024-06-18)
- ✅ employees Collection Schema definiert
- ✅ useEmployees Hook implementiert
- ✅ Admin Panel erweitert
- ✅ CreateTicketModal erweitert
- ✅ Status-Automatik implementiert
- ✅ Dokumentation erstellt
+77
View File
@@ -0,0 +1,77 @@
# Lokale Entwicklung (Appwrite)
## Warum funktioniert es auf dem Server, aber nicht mit `npm run dev`?
| | Server (`ticket.webklar.com`) | Lokal (`localhost:5173`) |
|---|---|---|
| App-URL | `https://ticket.webklar.com` | `http://localhost:5173` |
| API-URL | `https://ticket.webklar.com/v1` (nginx-Proxy) | oft `https://ticket.webklar.com/v1` direkt |
| **Gleiche Origin?** | Ja | **Nein** (Cross-Origin) |
| Session-Cookie | `domain=.ticket.webklar.com` | Browser speichert Cookie **nicht** f�r localhost |
Auf dem Server leitet nginx `/v1/` an Appwrite weiter und schreibt Cookies auf `ticket.webklar.com` um. Die React-App und die API sind **dieselbe Site** � Login funktioniert.
Lokal ruft der Browser die API auf einer **anderen Domain** auf. Appwrite setzt Cookies f�r `.ticket.webklar.com`. Die werden bei Requests von `localhost:5173` **nicht mitgeschickt** ? `guests missing scopes`, Login scheint tot.
CORS f�r `http://localhost:5173` ist auf dem Server erlaubt � das reicht allein nicht f�r HttpOnly-Session-Cookies.
## L�sung: Vite-Proxy + `.env.local`
1. Datei anlegen (nicht in Git):
```bash
cp .env.example .env.local
```
2. In `.env.local` muss stehen:
```env
VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
VITE_APPWRITE_PROJECT_ID=6a1058610003c5a13a05
VITE_APPWRITE_DATABASE_ID=woms-database
VITE_APPWRITE_BUCKET_ID=woms-attachments
```
3. Dev-Server starten:
```bash
npm install
npm run dev
```
`vite.config.js` leitet `/v1` an `https://ticket.webklar.com` weiter und schreibt Cookie-Domains auf `localhost` um � Verhalten wie nginx auf dem Server.
## Fehler: „Failed to load resource“ / **504** in der Konsole
Typisch bei `VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1`: Der Vite-Proxy erreicht Appwrite nicht rechtzeitig.
1. **Neuesten Code holen** (`git pull`) — Proxy zeigt jetzt direkt auf `appwrite.webklar.com` mit längerem Timeout.
2. Dev-Server **komplett stoppen** (Strg+C) und neu starten: `npm run dev`
3. In `.env.local` prüfen:
```env
VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
```
4. Im Browser **Network**-Tab: Welche URL liefert 504?
- `http://localhost:5173/v1/...` ? Proxy/Netzwerk (Firewall, VPN, Server nicht erreichbar)
- `https://ticket.webklar.com/v1/...` ? ohne Proxy; Endpoint in `.env.local` auf `localhost:5173` stellen
5. Test im Terminal (ersetzt 5173 durch deinen Vite-Port):
```bash
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:5173/v1/health \
-H "X-Appwrite-Project: 6a1058610003c5a13a05"
```
Erwartung: **401** (nicht 504) — dann ist der Proxy ok.
**Notfall ohne Proxy:** In `.env.local` temporär `VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1` — Login-Cookies funktionieren lokal dann oft nicht, aber du siehst ob der Server erreichbar ist.
## Checkliste bei Problemen
- [ ] `.env.local` existiert (`.env` allein reicht; Vite l�dt `.env.local` mit h�herer Priorit�t)
- [ ] Endpoint ist `http://localhost:5173/v1`, **nicht** `https://appwrite.webklar.com/v1` (altes Projekt)
- [ ] Nach Login: DevTools ? Application ? Cookies ? `localhost` ? `a_session_6a1058610003c5a13a05`
- [ ] Appwrite Console ? Projekt **Ticket-System** ? Platforms ? `localhost` eingetragen
## Git / Deploy
- `.env` und `.env.local` sind in `.gitignore` � jeder Entwickler braucht eigene `.env.local`
- Server-Build nutzt Container-Env (`VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1`)
- Push nach Gitea deployt nur den **Frontend-Code**, nicht deine lokale `.env.local`
-117
View File
@@ -1,117 +0,0 @@
# Login-Problembehebung
Wenn du dich nicht einloggen kannst, folge diesen Schritten:
## 1. Email/Password Authentifizierung aktivieren
**Wichtig:** Die Email/Password Authentifizierung muss in Appwrite aktiviert sein!
1. Gehe zu deinem Appwrite Dashboard: [https://cloud.appwrite.io](https://cloud.appwrite.io)
2. Wähle dein Projekt **woms** aus
3. Gehe zu **Auth** im linken Menü
4. Klicke auf **Providers** oder **Settings**
5. Suche nach **Email/Password** und aktiviere es
6. Stelle sicher, dass **Email/Password** aktiviert ist (grüner Schalter)
## 2. Ersten Benutzer erstellen
Du hast zwei Möglichkeiten:
### Option A: Über die App registrieren
1. Öffne die Login-Seite
2. Klicke auf **"Noch kein Account? Hier registrieren"**
3. Gib deine Email und ein Passwort (mindestens 8 Zeichen) ein
4. Klicke auf **Registrieren**
### Option B: Über Appwrite Dashboard erstellen
1. Gehe zu **Auth** → **Users** im Appwrite Dashboard
2. Klicke auf **Create User**
3. Wähle **Email/Password** als Methode
4. Gib Email und Passwort ein
5. Klicke auf **Create**
6. Logge dich dann in der App ein
## 3. Häufige Fehlermeldungen
### "Ungültige Email oder Passwort"
- Überprüfe, ob die Email korrekt eingegeben wurde
- Überprüfe, ob das Passwort korrekt ist
- Stelle sicher, dass der Benutzer in Appwrite existiert
### "Benutzer nicht gefunden"
- Der Benutzer existiert noch nicht → Registriere dich zuerst
- Oder erstelle den Benutzer im Appwrite Dashboard
### "Email/Password Authentifizierung ist nicht aktiviert"
- Gehe zu **Auth** → **Providers** in Appwrite
- Aktiviere **Email/Password**
### "Ein Benutzer mit dieser Email existiert bereits"
- Der Benutzer existiert bereits → Logge dich ein statt zu registrieren
- Oder verwende eine andere Email-Adresse
## 4. Überprüfe die .env Datei
Stelle sicher, dass deine `.env` Datei korrekt ist:
```env
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
```
**Wichtig:** Nach Änderungen an der `.env` Datei musst du den Dev-Server neu starten!
## 5. Browser-Konsole überprüfen
1. Öffne die Browser-Entwicklertools (F12)
2. Gehe zum Tab **Console**
3. Versuche dich einzuloggen
4. Schaue nach Fehlermeldungen in der Konsole
Häufige Fehler:
- `401 Unauthorized` → Falsche Credentials oder Auth nicht aktiviert
- `404 Not Found` → Falsche Project ID oder Endpoint
- `Network Error` → Internetverbindung oder CORS-Problem
## 6. Dev-Server neu starten
Wenn du die `.env` Datei geändert hast:
```bash
# Stoppe den Server (Ctrl+C)
# Starte ihn neu:
npm run dev
```
## 7. Cache leeren
Manchmal hilft es, den Browser-Cache zu leeren:
- Chrome/Edge: Ctrl+Shift+Delete (Windows) oder Cmd+Shift+Delete (Mac)
- Firefox: Ctrl+Shift+Delete (Windows) oder Cmd+Shift+Delete (Mac)
## 8. Test mit Demo-Modus
Um zu testen, ob die App grundsätzlich funktioniert, kannst du temporär die `.env` Datei umbenennen:
```bash
mv .env .env.backup
```
Dann läuft die App im Demo-Modus und du kannst dich mit beliebigen Credentials einloggen.
**Wichtig:** Benenne die Datei wieder um, wenn du Appwrite verwenden möchtest:
```bash
mv .env.backup .env
```
## Noch Probleme?
Wenn nichts hilft:
1. Überprüfe, ob dein Appwrite-Projekt aktiv ist
2. Überprüfe, ob die Project ID korrekt ist
3. Versuche, einen neuen Benutzer im Appwrite Dashboard zu erstellen
4. Überprüfe die Browser-Konsole auf detaillierte Fehlermeldungen
Viel Erfolg! 🚀
-76
View File
@@ -1,76 +0,0 @@
# Berechtigungsproblem beheben
## Problem
Du siehst den Fehler: **"401 (Unauthorized)"** oder **"The current user is not authorized to perform the requested action"**
Das bedeutet: Du bist eingeloggt, aber die Collection-Berechtigungen erlauben dir nicht, die Tickets zu lesen.
## Lösung: Berechtigungen in Appwrite konfigurieren
### Schritt 1: Collection-Berechtigungen überprüfen
1. Gehe zu `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Databases** → **woms-database** → **workorders**
4. Klicke auf den Tab **Settings**
5. Scrolle zu **Permissions**
### Schritt 2: Read-Berechtigung hinzufügen
**Option A: Für alle Benutzer (empfohlen für Entwicklung)**
- Klicke auf **Add Role** unter "Read"
- Wähle **Any** aus
- Klicke auf **Create**
**Option B: Nur für eingeloggte Benutzer**
- Klicke auf **Add Role** unter "Read"
- Wähle **Users** aus
- Klicke auf **Create**
### Schritt 3: Weitere Berechtigungen hinzufügen
Für vollständige Funktionalität füge auch hinzu:
**Create (Erstellen):**
- **Users** - damit eingeloggte Benutzer Tickets erstellen können
**Update (Aktualisieren):**
- **Users** - damit eingeloggte Benutzer Tickets bearbeiten können
**Delete (Löschen):**
- **Users** - damit eingeloggte Benutzer Tickets löschen können (optional)
### Schritt 4: Überprüfen
Nach dem Hinzufügen der Berechtigungen:
1. **Logge dich aus** und wieder **ein** (um die Session zu aktualisieren)
2. Oder lade die Seite neu (F5)
3. Die Tickets sollten jetzt geladen werden
## Aktuelle Konfiguration
- **Collection ID**: `6943bf7d001901baa60c`
- **Database ID**: `6943bf0e0003291f8c35`
- **Benötigte Berechtigung**: Read → **Any** oder **Users**
## Wenn es immer noch nicht funktioniert
1. **Überprüfe, ob du eingeloggt bist:**
- Schaue in die Browser-Konsole
- Es sollte keine 401-Fehler beim `account.get()` geben
2. **Überprüfe die Session:**
- Gehe zu **Auth** → **Sessions** im Appwrite Dashboard
- Stelle sicher, dass eine aktive Session existiert
3. **Teste mit "Any" Berechtigung:**
- Füge temporär **Any** als Read-Berechtigung hinzu
- Wenn das funktioniert, liegt das Problem bei der "Users" Berechtigung
4. **Überprüfe die Collection ID:**
- Stelle sicher, dass die Collection ID `6943bf7d001901baa60c` korrekt ist
- Überprüfe im Dashboard unter **Settings** → **General**
Viel Erfolg! 🚀
-142
View File
@@ -1,142 +0,0 @@
# Project ID Fehler beheben
Wenn du die Fehlermeldung bekommst: **"Project with the requested ID could not be found"**, folge diesen Schritten:
## Schritt 1: Project ID im Appwrite Dashboard überprüfen
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
2. Logge dich ein
3. Wähle dein Projekt **woms** aus
4. Gehe zu **Settings** → **General**
5. Suche nach **Project ID**
6. **Kopiere die Project ID** (sie sollte so aussehen: `693d9f37000b35267f1b`)
## Schritt 2: .env Datei aktualisieren
1. Öffne die `.env` Datei im Root-Verzeichnis deines Projekts
2. Überprüfe, ob die Project ID korrekt ist:
```env
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
```
3. **Wichtig:** Stelle sicher, dass:
- Keine Leerzeichen vor oder nach den Werten sind
- Keine Anführungszeichen um die Werte sind
- Die Project ID genau so ist wie im Dashboard
## Schritt 3: Dev-Server neu starten
**WICHTIG:** Nach jeder Änderung an der `.env` Datei musst du den Dev-Server **komplett neu starten**!
1. Stoppe den laufenden Server (falls er läuft):
- Drücke `Ctrl+C` im Terminal
2. Starte den Server neu:
```bash
npm run dev
```
## Schritt 4: Browser-Konsole überprüfen
1. Öffne die App im Browser
2. Öffne die Browser-Entwicklertools (F12)
3. Gehe zum Tab **Console**
4. Du solltest jetzt Debug-Informationen sehen:
```
🔧 Appwrite Konfiguration:
Endpoint: https://cloud.appwrite.io/v1
Project ID: 693d9f37000b35267f1b
Database ID: 6943bf0e0003291f8c35
```
5. Wenn du `NICHT GESETZT` siehst, bedeutet das, dass die `.env` Datei nicht geladen wird
## Schritt 5: Überprüfe die .env Datei-Position
Die `.env` Datei **MUSS** im Root-Verzeichnis sein (gleiche Ebene wie `package.json`):
```
tickte-system/
├── .env ← HIER!
├── package.json
├── vite.config.js
├── src/
└── ...
```
## Schritt 6: Cache leeren
Manchmal hilft es, den Vite-Cache zu löschen:
```bash
# Stoppe den Server
# Dann führe aus:
rm -rf node_modules/.vite
npm run dev
```
## Schritt 7: Projekt-Status überprüfen
1. Gehe zu deinem Appwrite Dashboard
2. Überprüfe, ob das Projekt **aktiv** ist
3. Überprüfe, ob du **Zugriff** auf das Projekt hast
4. Stelle sicher, dass das Projekt nicht gelöscht oder deaktiviert wurde
## Schritt 8: Alternative: Project ID direkt testen
Du kannst die Project ID auch direkt in der Browser-Konsole testen:
1. Öffne die Browser-Konsole (F12)
2. Führe aus:
```javascript
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID)
```
Wenn `undefined` ausgegeben wird, wird die `.env` Datei nicht geladen.
## Häufige Fehler
### ❌ Falsch:
```env
VITE_APPWRITE_PROJECT_ID = "693d9f37000b35267f1b" # Mit Leerzeichen und Anführungszeichen
```
### ✅ Richtig:
```env
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Ohne Leerzeichen, ohne Anführungszeichen
```
### ❌ Falsch:
```env
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Mit Leerzeichen am Ende
```
### ✅ Richtig:
```env
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Keine Leerzeichen
```
## Wenn nichts hilft
1. Erstelle eine neue `.env` Datei:
```bash
rm .env
cat > .env << 'EOF'
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
EOF
```
2. Starte den Server neu:
```bash
npm run dev
```
3. Überprüfe die Browser-Konsole auf die Debug-Ausgaben
Viel Erfolg! 🚀
+36 -41
View File
@@ -1,49 +1,44 @@
# WOMS 2.0 - Work Order Management System
# WOMS – Ticketsystem von WEBklar
React-basiertes Ticket-Management-System mit Appwrite Backend.
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Finanzen (Rechnungen
schreiben und verwalten, Buchungen zuordnen; nur für Admins).
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`).
## 🚀 Schnellstart
## Entwickeln und live stellen
### 1. Dependencies installieren
```bash
npm install
```
- Lokal starten: siehe [LOCAL_DEV.md](./LOCAL_DEV.md) (Vite-Proxy + `.env.local`).
- Live ist der Branch `test` im Gitea-Repo `JUSN/tickte-system`. Ein Push löst
auf dem Server Build und Deploy aus.
- Achtung: Der Webhook baut **jeden** gepushten Branch und stellt ihn live.
Feature-Branches deshalb nicht pushen; live geht es mit
`git push origin <branch>:test`.
### 2. Appwrite einrichten
## Datenmodell (Kurzfassung)
**Wichtig:** Für eine detaillierte Schritt-für-Schritt-Anleitung siehe [APPWRITE_SETUP.md](./APPWRITE_SETUP.md)
Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`).
Kurzfassung:
1. Erstelle ein Appwrite-Projekt auf [cloud.appwrite.io](https://cloud.appwrite.io)
2. Erstelle eine `.env` Datei im Root-Verzeichnis:
```env
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=deine-project-id-hier
VITE_APPWRITE_DATABASE_ID=woms-database
```
3. Folge der detaillierten Anleitung in `APPWRITE_SETUP.md` um die Datenbank-Struktur zu erstellen
| Collection | Inhalt | Wichtige Felder |
|---|---|---|
| `workorders` | Tickets | `woid` (fortlaufend ab 10000), `topic`, `details`, `status`, `priority`, `type` (Webpage, Migration, Project, Akquise), `serviceType`, `customerId`/`customerName`, `assignedTo`/`assignedName`, `requestedBy`, `startDate`/`startTime`/`endTime`, `deadline` |
| `worksheets` | Arbeitsblätter | `wsid` (fortlaufend ab 100000), `woid` + `workorderId`, `employeeId`/`employeeName`, `serviceType`, `oldStatus`/`newStatus`, `totalTime` (Minuten), `startDate`/`startTime`/`endDate`/`endTime`, `details`, `isComment`, `countsToPlan` (Abo-Stunden) |
| `customers` | Kunden und Leads | `code` (Kundennummer), `name`/`companyName`, `customerStatus` (z. B. lead, customer, lost), `archived`, Rechnungsadresse, Portal-Zugang, `invoiceNinjaClientId` (nicht mehr genutzt, Daten bleiben) |
| `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) |
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
| `roadmaps`, `roadmapNodes` | alte Projektpläne | wird von WOMS nicht mehr gelesen (Plan mit Schritten, verknüpft mit einem Ticket) |
| `config` | alte Einstellungen | wird von WOMS nicht mehr gelesen (Prioritäten, alte Listen) |
### 3. App starten
```bash
npm run dev
```
Zusammenspiel mit anderen Systemen:
Die App läuft dann auf `http://localhost:5173`
## 📚 Dokumentation
- **[APPWRITE_SETUP.md](./APPWRITE_SETUP.md)** - Detaillierte Anleitung zur Appwrite-Einrichtung
- Schritt-für-Schritt Setup
- Datenbank-Schema
- Berechtigungen
- Fehlerbehebung
## Features
- Ticket-Erstellung und -Verwaltung
- Status-Tracking (Open, Occupied, Assigned, Awaiting, Closed)
- Prioritäts-Management (Low, Normal, High, Critical)
- Filter und Suche
- Datei-Uploads
- Dashboard mit Statistiken
- Planboard für Terminplanung
- **Kundenportal** (project.webklar.com): legt bei jedem Git-Push ein Arbeitsblatt an
(`employeeId` `gitea`, `serviceType` `GIT`, ohne Beginn). Die Zeit wird in WOMS
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
und Projektverwaltung laufen über die Admin-API des Portals.
- **Finanzen-API des Portals** (`/api/admin/finanzen`): Übersicht, Rechnungen (Stripe),
Buchungen (Bank), Einstellungen und Fixkosten. WOMS spricht sie mit dem Appwrite-JWT an
und zeigt sie unter `/finance` (Einstellungen: `/finance/einstellungen`).
- **ticket-integrations** (`/api/integrations`): wird von WOMS nicht mehr genutzt
(weder InvoiceNinja noch Paperless; Paperless selbst ist gestoppt).
-141
View File
@@ -1,141 +0,0 @@
# WOID Attribut für workorders Collection
## Was ist die WOID?
Die **WOID (Work Order ID)** ist eine **5-stellige Zahl** (10000-99999), die jedes Ticket eindeutig identifiziert.
Beispiele: `10234`, `54321`, `99999`
## Appwrite Setup
Das `woid` Attribut muss zur `workorders` Collection hinzugefügt werden:
### Schritt 1: Öffne Appwrite
1. Gehe zu: `https://appwrite.webklar.com`
2. Öffne dein Projekt: **woms** (ID: `693d9f37000b35267f1b`)
3. Gehe zu **Databases** → **woms-database** (ID: `6943bf0e0003291f8c35`)
4. Öffne die Collection: **workorders** (ID: `6943bf7d001901baa60c`)
### Schritt 2: Erstelle das woid Attribut
1. Klicke auf **Attributes** Tab
2. Klicke auf **Create Attribute**
3. Wähle **String** als Typ
4. Konfiguration:
- **Attribute Key**: `woid`
- **Size**: 10
- **Required**: ❌ Nein (für bestehende Tickets ohne WOID)
- **Array**: ❌ Nein
- **Default Value**: (leer lassen)
5. Klicke auf **Create**
### Schritt 3: Optional - Index erstellen
Für schnellere Suche nach WOID:
1. Gehe zum **Indexes** Tab
2. Klicke auf **Create Index**
3. Konfiguration:
- **Index Key**: `woid_index`
- **Type**: Key
- **Attributes**: Wähle `woid`
- **Order**: ASC
4. Klicke auf **Create**
## Wie funktioniert die WOID?
### Automatische Generierung
Beim Erstellen eines neuen Tickets wird automatisch eine 5-stellige WOID generiert:
```javascript
// Beispiel: 12345, 54321, 99999
const generateWOID = () => {
const timestamp = Date.now().toString()
const random = Math.floor(Math.random() * 90000) + 10000
const combined = (parseInt(timestamp.slice(-5)) + random) % 90000 + 10000
return combined.toString().padStart(5, '0')
}
```
### Anzeige in der App
- **Ticket-Liste**: Zeigt die WOID in der ersten Spalte
- **Ticket-Details**: WOID im Header
- **Filter**: Suche nach WOID möglich
### Format
- **Länge**: Immer 5 Zeichen
- **Typ**: Nur Zahlen (0-9)
- **Bereich**: 10000-99999
- **Beispiele**: `10234`, `54321`, `99999`
## Bestehende Tickets
Tickets die vor diesem Update erstellt wurden, haben möglicherweise keine WOID.
**Fallback:** Wenn keine WOID vorhanden ist, werden die letzten 5 Zeichen der Document ID angezeigt.
## Fehlerbehebung
### "Unknown attribute: woid"
**Problem:** Das `woid` Attribut existiert nicht in der Collection.
**Lösung:** Folge Schritt 2 oben, um das Attribut zu erstellen.
### WOID zeigt Buchstaben
**Problem:** Alte Tickets haben keine WOID und verwenden den Fallback (Document ID).
**Lösung:**
1. Erstelle das `woid` Attribut (Schritt 2)
2. Neue Tickets bekommen automatisch eine WOID
3. Optional: Bestehende Tickets manuell aktualisieren
### WOID Duplikate
**Problem:** Zwei Tickets haben die gleiche WOID (sehr unwahrscheinlich).
**Lösung:** Der Algorithmus verwendet Timestamp + Zufallszahl für Eindeutigkeit. Bei Millionen von Tickets könnte theoretisch eine Kollision auftreten.
**Verbesserung:** Verwende einen Index mit `unique: true` und behandle Fehler beim Erstellen.
## Technische Details
### Datentyp in Appwrite
- **String** (nicht Integer), weil:
- Führende Nullen möglich (z.B. `00123`)
- Einfacher zu suchen und zu filtern
- Kompatibel mit verschiedenen Formaten
### Speicherung
```javascript
workorderData = {
// ...
woid: "12345", // String mit 5 Ziffern
// ...
}
```
### Anzeige
```javascript
// In TicketRow.jsx
<div>{ticket.woid || ticket.$id?.slice(-5)}</div>
```
## Fertig!
Nach dem Erstellen des `woid` Attributs:
- ✅ Neue Tickets bekommen automatisch eine 5-stellige WOID
- ✅ WOID wird in der Ticket-Liste angezeigt
- ✅ Suche nach WOID funktioniert
- ✅ Keine Buchstaben mehr in der WOID
-107
View File
@@ -1,107 +0,0 @@
# Workorder Collection Attribute beheben
Der Fehler "Unknown attribute: 'status'" bedeutet, dass das Attribut `status` in deiner `workorders` Collection fehlt.
## Problem
Die Collection `workorders` (ID: `6943bf7d001901baa60c`) hat nicht alle benötigten Attribute.
## Lösung: Attribute hinzufügen
1. Gehe zu `https://appwrite.webklar.com`
2. Öffne dein Projekt **woms**
3. Gehe zu **Databases** → **woms-database** → **workorders**
4. Klicke auf **Attributes** Tab
5. Füge die fehlenden Attribute hinzu:
### Erforderliche Attribute (Required):
| Attribut Name | Typ | Größe | Required | Default |
|--------------|-----|-------|----------|---------|
| `topic` | string | 255 | ✓ | - |
| `status` | string | 50 | ✓ | `Open` |
| `priority` | integer | - | ✓ | `1` |
### Optionale Attribute:
| Attribut Name | Typ | Größe | Required |
|--------------|-----|-------|----------|
| `type` | string | 50 | - |
| `systemType` | string | 50 | - |
| `responseLevel` | string | 50 | - |
| `serviceType` | string | 50 | - |
| `customerId` | string | 50 | - |
| `customerName` | string | 255 | - |
| `customerLocation` | string | 255 | - |
| `requestedBy` | string | 255 | - |
| `requestedFor` | string | 255 | - |
| `startDate` | string | 50 | - |
| `startTime` | string | 10 | - |
| `deadline` | string | 50 | - |
| `endTime` | string | 10 | - |
| `estimate` | string | 50 | - |
| `mailCopyTo` | string | 255 | - |
| `sendNotification` | boolean | - | - |
| `details` | string | 10000 | - |
| `assignedTo` | string | 50 | - |
| `assignedName` | string | 255 | - |
| `approvalStatus` | string | 50 | - |
| `woid` | string | 50 | - |
| `createdAt` | datetime | - | - |
## Schritt-für-Schritt
### 1. Status hinzufügen (wichtig!)
1. Klicke auf **Create Attribute**
2. **Key**: `status`
3. **Type**: `String`
4. **Size**: `50`
5. **Required**: ✓ (aktivieren)
6. **Default**: `Open`
7. **Array**: Nein
8. Klicke auf **Create**
### 2. Priority hinzufügen (wichtig!)
1. Klicke auf **Create Attribute**
2. **Key**: `priority`
3. **Type**: `Integer`
4. **Required**: ✓ (aktivieren)
5. **Default**: `1`
6. Klicke auf **Create**
### 3. Topic hinzufügen (wichtig!)
1. Klicke auf **Create Attribute**
2. **Key**: `topic`
3. **Type**: `String`
4. **Size**: `255`
5. **Required**: ✓ (aktivieren)
6. Klicke auf **Create**
### 4. Weitere Attribute hinzufügen
Wiederhole den Prozess für alle anderen Attribute aus der Liste oben.
## Überprüfung
Nach dem Hinzufügen aller Attribute:
1. Stelle sicher, dass mindestens diese drei Attribute existieren:
- ✓ `topic` (string, required)
- ✓ `status` (string, required, default: "Open")
- ✓ `priority` (integer, required, default: 1)
2. Versuche erneut, ein Ticket zu erstellen
## Wenn Attribute bereits existieren
Falls die Attribute bereits existieren, aber andere Namen haben:
1. Überprüfe die Attributnamen in Appwrite
2. Teile mir die tatsächlichen Attributnamen mit
3. Ich passe den Code entsprechend an
Viel Erfolg! 🚀
-210
View File
@@ -1,210 +0,0 @@
# Worksheets Collection Setup
## 📋 Was ist ein Worksheet (WSID)?
Ein **Worksheet** ist ein **Arbeitsschritt-Eintrag** für einen Work Order (Ticket). Jedes Mal, wenn ein Techniker an einem Ticket arbeitet, wird ein neues Worksheet erstellt.
### Unterschied: WOID vs WSID
| Konzept | Beschreibung | Beispiel |
|---------|--------------|----------|
| **WOID** (Work Order ID) | Das Haupt-Ticket | 59203 |
| **WSID** (Work Sheet ID) | Einzelner Arbeitsschritt | 100001 |
**Beziehung:** Ein WOID kann **viele WSIDs** haben (1:n)
### Beispiel aus der Praxis:
```
Ticket WOID 59203: "Router-Problem bei Kunde XYZ"
├── WSID 100001 (29.12.2025, 10:00-10:30, Max M.)
│ └── "Fernanalyse durchgeführt, Router neu gestartet"
│ Status: Open → Occupied
│
├── WSID 100002 (29.12.2025, 14:00-14:45, Max M.)
│ └── "Firmware Update durchgeführt"
│ Status: Occupied → Assigned
│
└── WSID 100003 (30.12.2025, 09:00-09:15, Lisa S.)
└── "Vor-Ort-Check: Alles funktioniert"
Status: Assigned → Closed
```
**Gesamtarbeitszeit für WOID 59203:** 30 + 45 + 15 = **90 Minuten**
---
## 🔧 Appwrite Collection einrichten
### Collection erstellen
1. Gehe zu **Appwrite Dashboard** → **Databases** → `woms-database`
2. Klicke auf **Create Collection**
3. **Collection ID:** `worksheets`
4. **Name:** `Work Sheets`
### Attribute hinzufügen
| Attribut Name | Typ | Größe | Required | Array | Default | Beschreibung |
|--------------|-----|-------|----------|-------|---------|--------------|
| `wsid` | string | 10 | ✓ | - | - | Work Sheet ID (6-stellig, z.B. "100000") |
| `woid` | string | 10 | ✓ | - | - | Verknüpfter Work Order (z.B. "59203") |
| `workorderId` | string | 50 | ✓ | - | - | Appwrite Document ID des Work Orders |
| `employeeId` | string | 50 | ✓ | - | - | ID des Mitarbeiters (aus Auth) |
| `employeeName` | string | 255 | ✓ | - | - | Name des Mitarbeiters |
| `employeeShort` | string | 10 | - | - | - | Kürzel (z.B. "KNSO") |
| `serviceType` | string | 50 | ✓ | - | `Remote` | Remote/On Site/Off Site/COMMENT |
| `oldStatus` | string | 50 | ✓ | - | - | Status vorher |
| `newStatus` | string | 50 | ✓ | - | - | Status nachher |
| `oldResponseLevel` | string | 50 | - | - | - | Response Level vorher |
| `newResponseLevel` | string | 50 | - | - | - | Response Level nachher |
| `totalTime` | integer | - | ✓ | - | `0` | Arbeitszeit in Minuten |
| `startDate` | string | 50 | ✓ | - | - | Startdatum (dd.mm.yyyy) |
| `startTime` | string | 10 | - | - | - | Startzeit (hhmm) |
| `endDate` | string | 50 | ✓ | - | - | Enddatum (dd.mm.yyyy) |
| `endTime` | string | 10 | - | - | - | Endzeit (hhmm) |
| `details` | string | 10000 | ✓ | - | - | Beschreibung der Arbeit |
| `isComment` | boolean | - | - | - | `false` | Nur Kommentar (keine Arbeitszeit) |
| `createdAt` | datetime | - | - | - | - | Erstellungszeitpunkt |
### Permissions einrichten
1. Gehe zu **Settings** → **Permissions**
2. Setze folgende Berechtigungen:
**Read Access:**
- ✅ `Users` (Alle eingeloggten Benutzer können Worksheets lesen)
**Create Access:**
- ✅ `Users` (Alle eingeloggten Benutzer können Worksheets erstellen)
**Update Access:**
- ✅ `Users` (Eigene Worksheets können bearbeitet werden)
**Delete Access:**
- ❌ Keine (Worksheets sollten nicht gelöscht werden - Audit Trail!)
### Indexes erstellen
Für bessere Performance:
1. **Index 1: woid_index**
- Key: `woid`
- Type: `key`
- Order: `DESC`
- → Schnelles Finden aller Worksheets für einen Work Order
2. **Index 2: employee_index**
- Key: `employeeId`
- Type: `key`
- Order: `DESC`
- → Schnelles Finden aller Worksheets eines Mitarbeiters
3. **Index 3: date_index**
- Key: `startDate`
- Type: `key`
- Order: `DESC`
- → Zeitbasierte Abfragen
---
## 🎯 WSID-Generierung
### Regeln:
- **6-stellige Zahlen** (100000, 100001, 100002, ...)
- **Global eindeutig** (über alle Work Orders hinweg)
- **Sequentiell aufsteigend**
- **Nur Zahlen**, keine Buchstaben
### Algorithmus:
1. Finde höchste existierende WSID in der Datenbank
2. Addiere +1
3. Falls keine WSID existiert: Starte bei 100000
---
## 📊 Verbesserungen gegenüber dem Original
### 1. Automatische Zeitberechnung
- System berechnet `totalTime` aus `startTime` und `endTime`
- Manuelles Überschreiben möglich
### 2. Arbeitszeit-Aggregation
- Gesamtarbeitszeit pro WOID wird automatisch berechnet
- Anzeige: "Gesamt: 90 Minuten (3 Worksheets)"
### 3. Status-Historie
- Komplette Historie: Wer hat wann welchen Status gesetzt?
- Nachvollziehbarkeit
### 4. Kommentar-Funktion
- Worksheets mit `isComment = true` zählen keine Arbeitszeit
- Reine Notizen/Updates
### 5. Mitarbeiter-Zuordnung
- Jedes Worksheet ist einem Mitarbeiter zugeordnet
- Report: Wer hat wie viel Zeit investiert?
---
## 🔗 Integration in Work Orders
### Beim Anzeigen eines Tickets:
```javascript
// Work Order laden
const workorder = await getWorkorder(woid)
// Alle Worksheets zu diesem Work Order laden
const worksheets = await getWorksheets(woid)
// Gesamtarbeitszeit berechnen
const totalMinutes = worksheets.reduce((sum, ws) => sum + ws.totalTime, 0)
```
### Beim Erstellen eines Worksheets:
1. Aktuellen Work Order Status holen
2. Worksheet-Formular anzeigen (mit aktuellem Status als "old status")
3. Worksheet speichern
4. Work Order Status aktualisieren (auf "new status")
---
## 📝 Workflow-Beispiel
**Techniker Max öffnet Ticket 59203:**
1. Klickt auf "Add Worksheet"
2. System füllt aus:
- WOID: 59203
- Old Status: "Open"
- Employee: "Max Müller" (aus Login)
- Datum: heute
3. Max trägt ein:
- Service Type: "Remote"
- New Status: "Occupied"
- Start: 10:00, End: 10:30 (→ automatisch 30 min)
- Details: "Router neu gestartet"
4. System generiert WSID: 100001
5. Work Order Status wird auf "Occupied" aktualisiert
---
## ✅ Checkliste
- [ ] Collection `worksheets` in Appwrite erstellt
- [ ] Alle Attribute hinzugefügt
- [ ] Permissions konfiguriert
- [ ] Indexes erstellt
- [ ] Collection ID in `.env` / `appwrite.js` eingetragen
---
## 🚀 Nächste Schritte
Nach dem Setup:
1. `useWorksheets.js` Hook implementieren
2. `CreateWorksheetModal.jsx` Komponente erstellen
3. Worksheet-Liste in Ticket-Details einbauen
4. Zeitaggregation implementieren
-119
View File
@@ -1,119 +0,0 @@
# WSID Attribut Hinzufügen
Diese Anleitung zeigt dir, wie du das **WSID (Work Sheet ID)** Attribut zur `workorders` Collection hinzufügst.
## Was ist WSID?
- **WSID** = Work Sheet ID
- **Format**: 6-stellige Nummer (100000-999999)
- **Funktion**: Sequentielle ID ähnlich wie WOID, aber mit 6 Stellen
- **Beispiele**: 100000, 100001, 100002, etc.
## Schritt-für-Schritt Anleitung
### 1. Appwrite Dashboard öffnen
1. Gehe zu https://appwrite.webklar.com (oder deine Appwrite-Instanz)
2. Logge dich ein
3. Öffne dein Projekt **woms**
### 2. Zur workorders Collection navigieren
1. Klicke auf **Databases** im linken Menü
2. Wähle die Datenbank **woms-database** (ID: `6943bf0e0003291f8c35`)
3. Klicke auf die Collection **workorders** (ID: `6943bf7d001901baa60c`)
### 3. WSID Attribut erstellen
1. Klicke auf den Tab **Attributes**
2. Klicke auf **Create Attribute**
3. Wähle **String** als Typ
4. Fülle die Felder wie folgt aus:
**Attribut-Konfiguration:**
- **Attribute ID**: `wsid`
- **Attribute Name**: `wsid`
- **Size**: `10` (für 6-stellige Zahlen + Reserve)
- **Required**: ❌ Nein (nicht required, damit alte Tickets ohne WSID weiter funktionieren)
- **Array**: ❌ Nein
- **Default Value**: Leer lassen
5. Klicke auf **Create**
### 4. Index für WSID erstellen (Optional, aber empfohlen)
Ein Index verbessert die Performance beim Suchen und Filtern:
1. Klicke auf den Tab **Indexes**
2. Klicke auf **Create Index**
3. Fülle die Felder wie folgt aus:
**Index-Konfiguration:**
- **Key**: `wsid`
- **Type**: `key`
- **Attributes**: Wähle `wsid`
- **Order**: `ASC` (aufsteigend)
4. Klicke auf **Create**
### 5. Fertig! ✅
Das WSID-Attribut ist jetzt in deiner Collection verfügbar. Die Anwendung wird automatisch:
- Für neue Tickets eine sequentielle WSID generieren (startend bei 100000)
- Die WSID in der Ticket-Liste anzeigen
- Die WSID im Detail-View anzeigen
## Hinweise
### Sequentielle Generierung
Die WSID wird ähnlich wie WOID automatisch generiert:
```javascript
// Beispiel:
Erstes Ticket: WSID = 100000
Zweites Ticket: WSID = 100001
Drittes Ticket: WSID = 100002
// etc.
```
### Unterschied WOID vs WSID
| Feld | Stellen | Start | Format | Beispiel |
|------|---------|-------|--------|----------|
| WOID | 5 | 10000 | String | "10000" |
| WSID | 6 | 100000 | String | "100000" |
### Alte Tickets
Tickets, die vor dem Hinzufügen des WSID-Attributs erstellt wurden, haben keine WSID. In der UI wird dann "-" angezeigt.
## Troubleshooting
### Problem: Attribut kann nicht erstellt werden
**Lösung**:
- Überprüfe, ob das Attribut `wsid` nicht bereits existiert
- Stelle sicher, dass du die richtigen Berechtigungen hast (Admin)
### Problem: WSID wird nicht angezeigt
**Lösung**:
1. Überprüfe, ob das Attribut erfolgreich erstellt wurde
2. Lade die Seite neu (Strg+F5 / Cmd+Shift+R)
3. Erstelle ein neues Ticket zum Testen
### Problem: WSID startet nicht bei 100000
**Lösung**:
- Das ist normal, wenn bereits Tickets mit WSID existieren
- Die WSID wird immer sequentiell von der höchsten bestehenden WSID weitergezählt
## Support
Bei Fragen oder Problemen:
1. Überprüfe die Appwrite Logs
2. Schaue in die Browser Console (F12)
3. Überprüfe, ob alle Berechtigungen korrekt gesetzt sind
-325
View File
@@ -1,325 +0,0 @@
# WSID Implementation - Zusammenfassung
## ✅ Was wurde implementiert?
Das **WSID (Work Sheet ID) System** wurde vollständig implementiert, basierend auf dem Original NetWEB Systems WOMS 2.0.
---
## 📋 Implementierte Features
### 1. **Worksheets Collection** ✅
- Separate Collection für Arbeitsschritte
- 6-stellige WSID (100000, 100001, ...)
- Sequentielle, globale Nummerierung
- Beziehung zu Work Orders via `woid` und `workorderId`
### 2. **WSID-Generierung** ✅
- Automatische Generierung beim Erstellen eines Worksheets
- Sucht höchste existierende WSID und zählt +1
- Startet bei 100000 wenn keine Worksheets existieren
- Nur Zahlen, keine Buchstaben
### 3. **Worksheet-Erstellung** ✅
- Modal-Dialog zum Erstellen neuer Worksheets
- Felder:
- Service Type (Remote/On Site/Off Site/COMMENT)
- Status-Änderung (Old Status → New Status)
- Response Level-Änderung
- Arbeitszeit (manuell oder automatisch berechnet)
- Start/End Datum & Zeit
- Details (Arbeitsbeschreibung)
- Kommentar-Flag (ohne Zeiterfassung)
- Verknüpfung mit aktuellem Benutzer
### 4. **Worksheet-Anzeige** ✅
- Liste aller Worksheets für ein Ticket
- Zeitstrahl-Darstellung (Timeline)
- Anzeige von:
- WSID
- Mitarbeiter (Name + Kürzel)
- Arbeitszeit
- Service Type
- Status-Änderungen
- Details
- Unterscheidung zwischen Worksheets und Kommentaren
### 5. **Zeitaggregation** ✅
- Gesamtarbeitszeit pro Ticket
- Durchschnittszeit pro Worksheet
- Arbeitszeit pro Mitarbeiter
- Anzahl Worksheets
### 6. **Status-Historie** ✅
- Chronologische Auflistung aller Status-Änderungen
- Wer hat wann welchen Status gesetzt
- Nachvollziehbarkeit aller Änderungen
### 7. **Statistiken & Reports** ✅
- **Gesamtübersicht:**
- Anzahl Worksheets
- Gesamtarbeitszeit
- Anzahl Kommentare
- Durchschnitt pro Worksheet
- **Nach Mitarbeiter:**
- Arbeitszeit pro Person
- Anzahl Worksheets pro Person
- Mitarbeiter-Kürzel
- **Service Type Verteilung:**
- Wie viele Remote/On Site/etc.
- **Status-Historie Tabelle:**
- Alle Status-Änderungen chronologisch
- Mit Datum, Zeit, Mitarbeiter
### 8. **Automatische Zeitberechnung** ✅
- System berechnet automatisch Arbeitszeit aus Start- und Endzeit
- Manuelles Überschreiben möglich
- Unterstützt Overnight-Zeiten (z.B. 23:00 - 01:00)
### 9. **Kommentar-Funktion** ✅
- Worksheets können als "Nur Kommentar" markiert werden
- Zählen nicht zur Arbeitszeit
- Für Notizen, Updates, Kommunikation
---
## 🔧 Technische Details
### Architektur
```
Work Order (WOID) 1:n Worksheets (WSID)
↓ ↓
workorders worksheets
Collection Collection
```
### Collections
**workorders:**
- Enthält WOID (5-stellig)
- Enthält KEINE WSID mehr ✅
- Referenz: `ticket.woid`
**worksheets:**
- Enthält WSID (6-stellig)
- Referenziert WOID
- Referenziert workorderId (Appwrite Document ID)
- Enthält Mitarbeiter-Info
- Enthält Zeiterfassung
- Enthält Status-Änderungen
### Datenfluss
1. **Ticket öffnen:**
- System lädt Work Order
- System lädt alle Worksheets mit `woid = ticket.woid`
2. **Worksheet erstellen:**
- Benutzer klickt "Add Worksheet"
- System generiert neue WSID
- System speichert Worksheet
- Wenn Status geändert → Work Order wird aktualisiert
3. **Statistiken:**
- Werden in Echtzeit aus Worksheets berechnet
- Keine Duplikate, keine Caching-Probleme
---
## 📂 Neue Dateien
| Datei | Beschreibung |
|-------|--------------|
| `src/hooks/useWorksheets.js` | Hook für Worksheet-Operationen |
| `src/components/CreateWorksheetModal.jsx` | Modal zum Erstellen |
| `src/components/WorksheetList.jsx` | Liste aller Worksheets |
| `src/components/WorksheetStats.jsx` | Statistiken & Reports |
| `WORKSHEETS_COLLECTION_SETUP.md` | Setup-Anleitung |
| `WSID_IMPLEMENTATION_SUMMARY.md` | Diese Datei |
---
## 🔄 Geänderte Dateien
| Datei | Änderung |
|-------|----------|
| `src/hooks/useWorkorders.js` | WSID-Generierung **entfernt** ✅ |
| `src/components/TicketRow.jsx` | Worksheet-Integration hinzugefügt |
| `src/lib/appwrite.js` | WORKSHEETS Collection ID hinzugefügt |
| `APPWRITE_SETUP.md` | WSID aus workorders entfernt |
---
## 🚀 Setup-Schritte
### 1. Collection in Appwrite erstellen
```bash
1. Gehe zu Appwrite Dashboard → Databases → woms-database
2. Erstelle Collection "worksheets"
3. Füge alle Attribute hinzu (siehe WORKSHEETS_COLLECTION_SETUP.md)
4. Setze Permissions (Users: Read, Create, Update)
5. Erstelle Indexes (woid, employeeId, startDate)
6. Kopiere Collection ID
```
### 2. Collection ID in Code eintragen
```javascript
// src/lib/appwrite.js
export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c',
WORKSHEETS: 'DEINE_COLLECTION_ID_HIER', // ← Hier eintragen!
// ...
}
```
### 3. WSID aus workorders Collection entfernen (Optional)
Falls du bereits `wsid` als Attribut in der `workorders` Collection hast:
```bash
1. Gehe zu Appwrite Dashboard → workorders Collection
2. Gehe zu Attributes
3. Lösche Attribut "wsid" (falls vorhanden)
```
⚠️ **Achtung:** Stelle sicher, dass keine wichtigen Daten verloren gehen!
---
## 📊 Verbesserungen gegenüber Original
| Feature | Original | Neu |
|---------|----------|-----|
| **WSID-Generierung** | Manuell/unklar | Automatisch, sequentiell |
| **Zeitberechnung** | Manuell | Automatisch + manuell |
| **Statistiken** | Keine/begrenzt | Umfassend (Mitarbeiter, Service Type, Historie) |
| **Kommentare** | Unklar | Eigener Typ (ohne Zeit) |
| **Status-Historie** | Nicht dokumentiert | Vollständig nachvollziehbar |
| **Zeitaggregation** | Manuell | Automatisch |
| **Multi-Mitarbeiter** | Schwierig | Einfach trackbar |
---
## 🎯 Verwendung
### Worksheet erstellen
1. Öffne ein Ticket (Lock-Icon klicken)
2. Klicke "Add Worksheet"
3. Fülle Formular aus:
- Service Type wählen
- Neuen Status wählen
- Start/End Zeit eingeben (automatische Berechnung)
- Details schreiben
4. Klicke "CREATE NOW"
### Statistiken ansehen
Wenn Worksheets vorhanden sind, werden automatisch angezeigt:
- Gesamtübersicht (oben)
- Mitarbeiter-Statistiken
- Service Type Verteilung
- Status-Historie (falls Änderungen)
### Arbeitszeit tracken
Jedes Worksheet erfasst:
- Wer hat gearbeitet
- Wann (Datum + Zeit)
- Wie lange
- Was wurde gemacht
- Welcher Status wurde gesetzt
**Beispiel:**
```
WOID 59203: "Router-Problem"
├─ WSID 100001: Max, 30min, Open → Occupied, "Fernanalyse"
├─ WSID 100002: Max, 45min, Occupied → Assigned, "Firmware Update"
└─ WSID 100003: Lisa, 15min, Assigned → Closed, "Vor-Ort-Check OK"
Gesamtzeit: 90 Minuten
Mitarbeiter: Max (75min), Lisa (15min)
```
---
## ✅ Checkliste
- [x] Worksheets Collection Dokumentation erstellt
- [x] useWorksheets Hook mit WSID-Generierung
- [x] CreateWorksheetModal UI-Komponente
- [x] WorksheetList Komponente
- [x] WorksheetStats Komponente (Statistiken)
- [x] Integration in TicketRow
- [x] WSID aus workorders entfernt
- [x] Automatische Zeitberechnung
- [x] Status-Historie
- [x] Mitarbeiter-Tracking
- [x] Kommentar-Funktion
- [x] Service Type Tracking
- [ ] **Collection in Appwrite erstellen** ← Das musst DU machen!
- [ ] **Collection ID eintragen** ← Das musst DU machen!
---
## 🐛 Testing
Nach dem Setup:
1. **Worksheet erstellen:**
```
- Öffne ein Ticket
- Klicke "Add Worksheet"
- Fülle Formular aus
- Speichern
- → WSID sollte 100000 sein (erste)
```
2. **Zweites Worksheet:**
```
- Erstelle weiteres Worksheet
- → WSID sollte 100001 sein
```
3. **Statistiken prüfen:**
```
- Öffne Ticket mit mehreren Worksheets
- Statistiken sollten angezeigt werden
- Gesamtzeit sollte korrekt sein
```
4. **Status-Änderung:**
```
- Erstelle Worksheet mit Status-Änderung
- Work Order Status sollte aktualisiert werden
- Historie sollte angezeigt werden
```
---
## 📖 Weiterführende Dokumentation
- **Setup:** `WORKSHEETS_COLLECTION_SETUP.md`
- **Appwrite:** `APPWRITE_SETUP.md`
- **Code:** Siehe Inline-Kommentare in den Komponenten
---
## 🎉 Fertig!
Das WSID-System ist vollständig implementiert und einsatzbereit!
**Nächste Schritte:**
1. Collection in Appwrite erstellen
2. Collection ID eintragen
3. Testen!
Bei Fragen: Siehe Dokumentation oder Code-Kommentare.
-195
View File
@@ -1,195 +0,0 @@
# WSID System - Quick Start Guide
## 🚀 Schnellstart in 5 Minuten
### Schritt 1: Collection in Appwrite erstellen
1. Gehe zu **https://appwrite.webklar.com**
2. Login mit deinen Credentials
3. Öffne Projekt **woms**
4. Gehe zu **Databases** → **woms-database**
5. Klicke **Create Collection**
- Collection ID: `worksheets`
- Name: `Work Sheets`
6. Klicke **Create**
### Schritt 2: Attribute hinzufügen
Klicke auf **Attributes** und füge folgende Attribute hinzu:
#### Required Attributes (Pflicht):
```
1. wsid | String | Size: 10 | Required: ✓ | Default: -
2. woid | String | Size: 10 | Required: ✓ | Default: -
3. workorderId | String | Size: 50 | Required: ✓ | Default: -
4. employeeId | String | Size: 50 | Required: ✓ | Default: -
5. employeeName | String | Size: 255 | Required: ✓ | Default: -
6. serviceType | String | Size: 50 | Required: ✓ | Default: Remote
7. oldStatus | String | Size: 50 | Required: ✓ | Default: -
8. newStatus | String | Size: 50 | Required: ✓ | Default: -
9. totalTime | Integer | - | Required: ✓ | Default: 0
10. startDate | String | Size: 50 | Required: ✓ | Default: -
11. endDate | String | Size: 50 | Required: ✓ | Default: -
12. details | String | Size: 10000 | Required: ✓ | Default: -
13. createdAt | DateTime| - | Required: - | Default: -
```
#### Optional Attributes (Optional):
```
14. employeeShort | String | Size: 10 | Required: - | Default: -
15. oldResponseLevel | String | Size: 50 | Required: - | Default: -
16. newResponseLevel | String | Size: 50 | Required: - | Default: -
17. startTime | String | Size: 10 | Required: - | Default: -
18. endTime | String | Size: 10 | Required: - | Default: -
19. isComment | Boolean | - | Required: - | Default: false
```
### Schritt 3: Permissions setzen
1. Gehe zu **Settings** → **Permissions**
2. Setze folgende Berechtigungen:
**Read Access:**
- ✅ `Users` (All users)
**Create Access:**
- ✅ `Users` (All users)
**Update Access:**
- ✅ `Users` (All users)
**Delete Access:**
- ❌ Keine (für Audit Trail)
### Schritt 4: Indexes erstellen
Gehe zu **Indexes** und erstelle:
1. **woid_index**
- Key: `woid`
- Type: `key`
- Order: `DESC`
2. **employee_index**
- Key: `employeeId`
- Type: `key`
- Order: `DESC`
3. **date_index**
- Key: `startDate`
- Type: `key`
- Order: `DESC`
### Schritt 5: Collection ID kopieren
1. Gehe zurück zur Collection-Übersicht
2. Kopiere die **Collection ID** (z.B. `694xyz123abc456def789`)
### Schritt 6: Collection ID im Code eintragen
Öffne `src/lib/appwrite.js` und trage die Collection ID ein:
```javascript
export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c',
CUSTOMERS: '694bd1fb002b2e583d13',
EMPLOYEES: '695280510031c6c6153b',
WORKSHEETS: '694xyz123abc456def789', // ← Hier deine Collection ID eintragen!
// ...
}
```
### Schritt 7: Optional - WSID aus workorders entfernen
Falls du bereits ein `wsid`-Attribut in der `workorders` Collection hast:
1. Gehe zu **workorders** Collection
2. Gehe zu **Attributes**
3. Lösche Attribut `wsid` (falls vorhanden)
⚠️ **Hinweis:** Stelle sicher, dass du ein Backup hast!
### Schritt 8: Anwendung neu starten
```bash
# Terminal öffnen
npm run dev
```
### Schritt 9: Testen!
1. Öffne die Anwendung im Browser
2. Gehe zu **Tickets**
3. Öffne ein Ticket (Lock-Icon klicken)
4. Klicke **"Add Worksheet"**
5. Fülle das Formular aus:
- Service Type: `Remote`
- New Status: `Occupied`
- Start Time: `1000`
- End Time: `1030`
- Details: `Test-Worksheet`
6. Klicke **"CREATE NOW"**
**Erwartetes Ergebnis:**
- WSID sollte `100000` sein
- Worksheet sollte in der Liste erscheinen
- Statistiken sollten angezeigt werden
---
## ✅ Fertig!
Das WSID-System ist jetzt einsatzbereit!
### Nächste Schritte:
- Erstelle weitere Worksheets
- Prüfe Statistiken
- Teste Status-Änderungen
- Prüfe Zeitaggregation
---
## 🐛 Troubleshooting
### Problem: "Collection nicht gefunden"
**Lösung:**
- Überprüfe Collection ID in `src/lib/appwrite.js`
- Stelle sicher, dass Collection wirklich existiert
### Problem: "Unauthorized"
**Lösung:**
- Überprüfe Permissions in Appwrite
- `Users` muss Read/Create Zugriff haben
### Problem: "Attribute nicht gefunden"
**Lösung:**
- Überprüfe, ob alle Required Attributes erstellt wurden
- Attribute-Namen müssen exakt übereinstimmen (case-sensitive!)
### Problem: "WSID wird nicht generiert"
**Lösung:**
- Überprüfe Browser-Konsole auf Fehler
- Stelle sicher, dass `wsid` Attribut existiert
- Überprüfe, ob Collection ID korrekt ist
---
## 📚 Weitere Dokumentation
- **Detaillierte Setup-Anleitung:** `WORKSHEETS_COLLECTION_SETUP.md`
- **Implementierungs-Details:** `WSID_IMPLEMENTATION_SUMMARY.md`
- **Appwrite Setup:** `APPWRITE_SETUP.md`
---
## 🎉 Viel Erfolg!
Bei Fragen: Siehe ausführliche Dokumentation oder Code-Kommentare.
@@ -0,0 +1,240 @@
# Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem ⇄ Projekt-Admin – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mitarbeiter melden sich in Ticketsystem und Projekt-Admin nur mit Passwort + E-Mail-Code an (Appwrite-MFA), und ein Seitenleisten-Eintrag wechselt ohne zweiten Login ins Projekt-Admin, ein Kopfzeilen-Button zurück.
**Architecture:** Appwrite 1.8.1 erzwingt den zweiten Faktor (`user.mfa` + `emailVerification`). Das Ticketsystem (React, SDK 13) ruft die MFA-Endpunkte über `client.call` auf; das Kundenportal (Express) führt beim Mitarbeiter-Login die Challenge serverseitig mit der Appwrite-Sitzung aus der Login-Antwort durch. Der Wechsel nutzt die vorhandenen Einmal-Tokens aus `staffPreviewTokens.js` mit neuem `purpose: 'switch'`.
**Tech Stack:** Kundenportal: Node/Express, `node --test`, Appwrite-Nachbau `test/support/appwriteMock.js`. Ticketsystem: React 18 + Vite, Appwrite-Web-SDK 13.0.2, neu `node --test` für reine Module; UI-Prüfung per esbuild-Harness + Playwright im Scratchpad (Rezept: Memory „woms-ticketsystem“, `MemoryMax` beachten).
**Spec:** `docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md` (dieser Worktree)
**Repos / Worktrees:**
- Ticketsystem: `/home/webklar/apps/ticket.webklar.com/dev-2fa`, Branch `feature/mitarbeiter-2fa` (von `origin/test` 3b6a821). `node_modules` ist getrackt → nur `git add <dateien>`, nie `-A`. **Nie pushen** (Webhook deployt jeden Branch).
- Kundenportal: `/home/webklar/apps/kundenbereich.webklar.com/dev-2fa`, Branch `feature/mitarbeiter-2fa` von `origin/main` (d5c70a5), anlegen in Task 1 (`git -C build worktree add -b feature/mitarbeiter-2fa ../dev-2fa origin/main`, `node_modules` als Symlink auf `../build/node_modules`). Nicht pushen.
## Global Constraints
- Appwrite-Endpunkte (1.8.1): `POST /v1/account/mfa/challenges {factor:'email'}` → `{ $id, expire }`; `PUT /v1/account/mfa/challenges {challengeId, otp}`; Admin: `PATCH /v1/users/:id/mfa {mfa}`, `PATCH /v1/users/:id/verification {emailVerification}`.
- Fehlertypen: `user_more_factors_required` (401, halbe Sitzung), `user_invalid_token` (401, Code falsch/abgelaufen), `user_email_not_verified`, `general_rate_limit_exceeded` / Status 429.
- Code gilt 1 Stunde (Appwrite). Offene Portal-Code-Anmeldung: 10 Minuten, max. 5 Fehlversuche.
- Einmal-Link: 60 s, einmalig, `purpose: 'switch'`; Preview-Tokens ohne `purpose` gelten als `'preview'`.
- MFA-Aufrufe senden `X-Appwrite-Locale: de`.
- Texte (wörtlich, in beiden Systemen gleich):
- Hinweis: `Wir haben dir einen Code an {maskierteEmail} geschickt.`
- Feld-Label: `Code aus der E-Mail`; Buttons: `Anmelden`, `Code erneut senden`, `Abbrechen`, (halbe Sitzung beim Laden) `Code senden`
- `user_invalid_token` → `Code falsch oder abgelaufen.`
- 429 → `Zu viele Versuche. Bitte warte einige Minuten.`
- `user_email_not_verified` → `2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.`
- Portal nach 5 Fehlversuchen / abgelaufen → `Bitte melde dich erneut an.`
- E-Mail-Maskierung: erstes Zeichen des lokalen Teils + `…@` + Domain (`kenso@webklar.com` → `k…@webklar.com`).
- Seitenleiste: Label `Projekt-Admin`, Icon `IconExternalLink`; Kopfzeile Portal: `← Ticketsystem` → `https://ticket.webklar.com/tickets`.
- Kunden-Login und Mitarbeiter mit `mfa = false` verhalten sich exakt wie heute.
## Review Focus
1. **Portal-Umgehung:** Mitarbeiter mit `mfa = true` darf nach `POST /api/auth/login` **kein** `webklar_portal_session`- und kein Preview-Cookie bekommen – Test in Task 3.
2. **Reload mit halber Sitzung im Ticketsystem:** darf keine Mail automatisch auslösen und nicht in einer Login-Schleife enden – Test in Task 6 (UI-Harness).
3. **Token-Verwechslung:** Ein Switch-Token darf nicht über `/api/auth/staff-preview` einlösbar sein und umgekehrt – Test in Task 4.
4. **Halbe Sitzung beim Wechsel:** JWT aus halber Sitzung → `/account` 401 → `switch-link` 401, kein Token – Test in Task 4.
5. **Abbrechen/Neu-Login mit halber Sitzung im Ticketsystem:** `deleteSessions()` vor dem Neu-Login muss auch mit halber Sitzung klappen (in Appwrite zur MFA-Gruppe gehörend? – wenn nicht, wird der Fehler geschluckt wie heute und die lokale Sitzung trotzdem gelöscht) – Test in Task 5.
---
## Teil A – Kundenportal (`kundenbereich.webklar.com/dev-2fa`)
### Task 1: Appwrite-Sitzung aus dem Login + MFA-Aufrufe im Namen der Sitzung
**Files:**
- Modify: `server/services/appwriteClient.js` (`appwriteFetch`, `loginWithAppwrite`)
- Modify: `test/support/appwriteMock.js` (Sessions + MFA-Nachbau)
- Test: `test/staffMfa.test.js` (neu, erster Teil)
**Interfaces:**
- Produces:
- `loginWithAppwrite(email, password) → { $id, email, name: '', sessionCookie: string }` (`sessionCookie` = Wert von `a_session_<projectId>` aus `set-cookie`, sonst `''`).
- `appwriteSessionFetch(path, { method, body, sessionCookie }) → data` – wie `appwriteFetch`, zusätzlich Header `Cookie: a_session_<projectId>=<sessionCookie>` und `X-Appwrite-Locale: de`; Fehler mit `.status`, `.type` wie `appwriteFetch`.
- Mock: `POST /v1/account/sessions/email` (setzt `set-cookie a_session_<project>=sess-<userId>`; Nutzer aus `mock.addUser({ $id, email, password, mfa, emailVerification, labels })`), `GET /v1/users/:id` liefert `mfa`, `emailVerification`, `labels`, `email`; `GET /v1/account` mit Cookie → 401 `user_more_factors_required` solange mfa und Faktor fehlt; `POST/PUT /v1/account/mfa/challenges` (OTP abrufbar über `mock.lastOtp(userId)`, `mock.expireChallenges()`); `PATCH /v1/users/:id/mfa`, `PATCH /v1/users/:id/verification`; Zähler `mock.mailsSent(userId)`.
- [ ] **Step 1:** Worktree anlegen (siehe Kopf), `npm test` im Worktree → alle bestehenden Tests grün (Ausgangslage notieren).
- [ ] **Step 2: Failing test** in `test/staffMfa.test.js`:
- `loginWithAppwrite liefert sessionCookie` → `assert.equal(user.sessionCookie, 'sess-u_staff')`.
- `appwriteSessionFetch schickt Cookie und Locale` → Challenge anlegen, `mock.mailsSent('u_staff') === 1`, Mock hat Header `x-appwrite-locale: de` gesehen.
- [ ] **Step 3:** `npm test` → FAIL (`sessionCookie` undefined / Funktion fehlt).
- [ ] **Step 4:** Implementieren: `appwriteFetch` gibt intern auch die Response-Header zurück (`response.headers.getSetCookie()`), `loginWithAppwrite` nimmt das Cookie `a_session_${config.appwrite.projectId}`; `appwriteSessionFetch` exportieren. Mock erweitern.
- [ ] **Step 5:** `npm test` → PASS (alle).
- [ ] **Step 6: Commit** `git add server/services/appwriteClient.js test/support/appwriteMock.js test/staffMfa.test.js && git commit -m "Portal-Login: Appwrite-Sitzung merken, MFA-Aufrufe im Namen der Sitzung"`
### Task 2: Offene Code-Anmeldungen (`staffMfa.js`)
**Files:**
- Create: `server/services/staffMfa.js`
- Test: `test/staffMfa.test.js` (erweitern)
**Interfaces:**
- Consumes: `appwriteSessionFetch` (Task 1).
- Produces:
- `maskEmail(email: string) → string`
- `startStaffMfa({ userId, email, sessionCookie }) → Promise<{ key: string, maskedEmail: string }>` – legt Challenge an, Eintrag `{ userId, email, sessionCookie, challengeId, expiresAt: now+10min, attempts: 0 }` in einer `Map`.
- `verifyStaffMfa(key, otp) → Promise<{ ok: true, userId, email } | { ok: false, reason: 'invalid' | 'gone' }>` – `invalid` bei `user_invalid_token` (attempts+1; beim 5. Fehlversuch Eintrag löschen → danach `gone`), `gone` bei unbekanntem/abgelaufenem Schlüssel; bei Erfolg Eintrag löschen. Andere Fehler (z. B. 429) werden geworfen.
- `resendStaffMfa(key) → Promise<boolean>` – neue Challenge, `challengeId` ersetzen; `false` wenn Eintrag weg.
- `MFA_COOKIE = 'webklar_portal_mfa'`
- [ ] **Step 1: Failing tests:**
- `maskEmail`: `'kenso@webklar.com' → 'k…@webklar.com'`, `'a@b.de' → 'a…@b.de'`, `'' → ''`.
- `richtiger Code`: start → `verifyStaffMfa(key, mock.lastOtp('u_staff'))` → `{ ok: true, userId: 'u_staff', email }`; zweiter Aufruf mit gleichem Key → `{ ok:false, reason:'gone' }`.
- `falscher Code 5x`: vier Mal `reason:'invalid'`, fünfter `invalid`, sechster `gone`.
- `abgelaufen`: Zeit per `Date.now`-Stub +11 min → `gone`.
- `resend`: `mock.mailsSent` steigt auf 2, alter OTP ungültig (`invalid`), neuer gültig.
- [ ] **Step 2:** `npm test` → FAIL (Modul fehlt).
- [ ] **Step 3:** `server/services/staffMfa.js` implementieren (Schlüssel `randomBytes(32).toString('hex')`, Aufräumen wie `staffPreviewTokens.purgeExpired`).
- [ ] **Step 4:** `npm test` → PASS.
- [ ] **Step 5: Commit** `git add server/services/staffMfa.js test/staffMfa.test.js && git commit -m "Portal: offene Mitarbeiter-Code-Anmeldungen (staffMfa)"`
### Task 3: Login-Route mit Code-Schritt + Login-Seite
**Files:**
- Create: `server/services/staffAccount.js` (verschiebt `resolveStaffAccount` aus `routes/auth.js` unverändert, exportiert)
- Modify: `server/routes/auth.js` (Login, neue Routen, Helfer `startStaffPortalSession`)
- Modify: `public/login.html`, `public/app.js` (`initLoginPage`), `public/login.css` (nur falls nötig)
- Test: `test/staffLogin.test.js` (neu, mit `startTestApp({ '/api/auth': authRoutes })`)
**Interfaces:**
- Consumes: Task 1 + 2.
- Produces:
- `resolveStaffAccount(appwriteUserId, email, account) → { isAdmin, name, email } | null` (aus `server/services/staffAccount.js`).
- In `auth.js` lokal: `startStaffPortalSession(res, { userId, staff }) → Promise<void>` (setzt Portal-Sitzung + Preview-Cookie exakt wie heute der Mitarbeiter-Zweig; wird in Task 4 von `staff-switch` genutzt – deshalb exportieren als benannten Export).
- `POST /api/auth/login`: Mitarbeiter mit `account.mfa === true` → Status 200 `{ mfaRequired: true, email: maskedEmail }` + signiertes httpOnly-Cookie `webklar_portal_mfa` (10 min, `sameSite: 'lax'`, `secure` wie Portal-Cookie); **keine** Portal-Sitzung.
- `POST /api/auth/login/mfa {otp}` → 200 `{ success:true, role:'admin', admin }` | 401 `{ error:'Code falsch oder abgelaufen.' }` | 410 `{ error:'Bitte melde dich erneut an.' }` (+ MFA-Cookie löschen) | 429.
- `POST /api/auth/login/mfa/resend` → 200 `{ success:true }` | 410.
- [ ] **Step 1: Failing tests** (`test/staffLogin.test.js`):
- `Kunde ohne MFA: unverändert` → 200, Portal-Cookie gesetzt, kein `mfaRequired`.
- `Mitarbeiter ohne MFA: unverändert` → 200 `role:'admin'`, Portal-Cookie gesetzt.
- `Mitarbeiter mit MFA: kein Portal-Cookie vor dem Code` → 200 `{ mfaRequired:true, email:'k…@webklar.com' }`; `set-cookie` enthält `webklar_portal_mfa`, **nicht** `webklar_portal_session` und nicht das Preview-Cookie. `GET /api/auth/me` mit diesem Cookie-Stand → `authenticated:false`.
- `richtiger Code` → 200 `role:'admin'`, Portal-Cookie gesetzt, MFA-Cookie gelöscht; `/me` → `role:'admin'`.
- `falscher Code` → 401 Text wörtlich; nach 5 falschen → 410 `Bitte melde dich erneut an.`.
- `ohne MFA-Cookie` → 410.
- `resend` → 200, `mock.mailsSent` = 2.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Server implementieren: `resolveStaffAccount` verschieben; im Login nach `resolveStaffAccount` bei `account?.mfa === true` → `startStaffMfa`, sonst unverändert `startStaffPortalSession`. `staff` für den späteren Erfolg wird bei `verify` neu per `getUserById` + `resolveStaffAccount` ermittelt (nicht im Speicher halten).
- [ ] **Step 4:** `npm test` → PASS (alle, inkl. bestehender).
- [ ] **Step 5:** Login-Seite: bei `result.mfaRequired` Passwort-Formular ausblenden, Code-Schritt zeigen (Texte aus Global Constraints, Feld `inputmode="numeric" autocomplete="one-time-code" maxlength="6"`), `Abbrechen` = Seite neu laden (`/login.html`). Erfolg → `adminLoginTarget()`.
- [ ] **Step 6:** Sichtprüfung mit Playwright gegen den Test-Server aus Step 1 (Mock + App): Passwort → Code-Schritt sichtbar, falscher Code zeigt Text, richtiger Code landet auf `/admin.html`. Screenshot Handy- und Desktop-Breite.
- [ ] **Step 7: Commit** `git add server/services/staffAccount.js server/routes/auth.js public/login.html public/app.js test/staffLogin.test.js && git commit -m "Portal-Login: E-Mail-Code für Mitarbeiter mit 2FA"` (+ `public/login.css`, falls geändert)
### Task 4: Wechsel-Link + Rückweg-Button
**Files:**
- Modify: `server/services/staffPreviewTokens.js`
- Create: `server/routes/admin/switchLink.js`, mount in `server/index.js` als `app.use('/api/admin/switch-link', adminSwitchLinkRoutes)` neben `preview-open`
- Modify: `server/routes/auth.js` (neue Route `GET /staff-switch`, `staff-preview` prüft `purpose`)
- Modify: `public/admin.html` (Button in `.portal-header-actions` vor `#logout-btn`)
- Test: `test/staffSwitch.test.js` (neu)
**Interfaces:**
- Consumes: `resolveStaffAccount` (Task 3), `startStaffPortalSession` (Task 3).
- Produces:
- `createStaffPreviewToken(payload)` unverändert; `consumeStaffPreviewToken(token, purpose = 'preview')` → `null`, wenn `(entry.purpose || 'preview') !== purpose` (Token wird dabei trotzdem verbraucht).
- `POST /api/admin/switch-link` (Bearer-JWT): `/account` per JWT (wie `requireAppwriteStaff`, aber ohne Label-Pflicht), dann `getUserById` + `resolveStaffAccount`; kein Mitarbeiter → 403; → 200 `{ openUrl: 'https://<config.preview.baseHost>/api/auth/staff-switch?token=…' }`.
- `GET /api/auth/staff-switch?token=` → gültig: `startStaffPortalSession` + `302 /admin.html`; sonst `302 /login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden`.
- Button: `<a class="portal-btn-outline" href="https://ticket.webklar.com/tickets">← Ticketsystem</a>`.
- [ ] **Step 1: Failing tests:**
- `switch-link ohne JWT` → 401; `ungültiges JWT` → 401; `JWT aus halber Sitzung` (Mock liefert `user_more_factors_required`) → 401.
- `Kunde` (`jwt-kunde`, kein employee) → 403.
- `Mitarbeiter ohne admin-Label` (neues Mock-JWT `jwt-staff`, employee-Dokument vorhanden) → 200 mit `openUrl`.
- `staff-switch gültig` → 302 `/admin.html`, Portal-Cookie `role:'admin'`, `isAdmin` passend.
- `staff-switch zweimal` → zweiter Aufruf 302 auf `/login.html?error=…`.
- `staff-switch nach 61 s` → 302 Login.
- `Switch-Token über staff-preview` → 410; `Preview-Token über staff-switch` → 302 Login.
- `admin.html enthält Rückweg-Link` → statischer Test: Datei enthält `href="https://ticket.webklar.com/tickets"` innerhalb `portal-header-actions`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` → PASS (alle).
- [ ] **Step 5: Commit** `git add server/services/staffPreviewTokens.js server/routes/admin/switchLink.js server/index.js server/routes/auth.js public/admin.html test/staffSwitch.test.js && git commit -m "Wechsel Ticketsystem → Projekt-Admin per Einmal-Link, Rückweg-Button"`
### Task 5 (Portal): Einrichtungs-Skript `scripts/mitarbeiter-2fa.mjs`
**Files:**
- Create: `scripts/mitarbeiter-2fa.mjs`, `server/services/staffMfaAdmin.js`
- Test: `test/staffMfaAdmin.test.js`
**Interfaces:**
- Produces (`staffMfaAdmin.js`): `listStaffMfaStatus() → Promise<Array<{ userId, name, email, emailVerification, mfa }>>` (alle `employees` mit `userId`); `enableStaffMfa(userId)` (erst verification, dann mfa); `disableStaffMfa(userId)`.
- Skript: `--status` (Tabelle), `--enable <userId>|alle` (zeigt vorher die Adressen, fragt `Ja/Nein` auf stdin; `--yes` überspringt), `--disable <userId>`. Läuft per `docker exec -w /app webklar-kundenbereich-server node scripts/mitarbeiter-2fa.mjs …`.
- [ ] **Step 1: Failing tests:** `listStaffMfaStatus` gegen Mock (2 employees, 1 ohne `userId` → ignoriert); `enableStaffMfa` ruft `verification` **vor** `mfa` (Reihenfolge im Mock protokolliert); `disableStaffMfa` setzt nur `mfa:false`.
- [ ] **Step 2:** FAIL → **Step 3:** implementieren → **Step 4:** `npm test` PASS.
- [ ] **Step 5: Commit** `git add scripts/mitarbeiter-2fa.mjs server/services/staffMfaAdmin.js test/staffMfaAdmin.test.js && git commit -m "Skript: 2FA für Mitarbeiter anzeigen, einschalten, abschalten"`
## Teil B – Ticketsystem (`ticket.webklar.com/dev-2fa`)
### Task 6: MFA-Modul + AuthContext + Login-Seite
**Files:**
- Create: `src/lib/mfa.js`, `tests/mfa.test.mjs`
- Modify: `src/lib/appwrite.js` (`export { client }`), `src/context/AuthContext.jsx`, `src/pages/LoginPage.jsx`, `package.json` (`"test": "node --test tests/"`)
**Interfaces:**
- Produces (`src/lib/mfa.js`, **ohne** Import von `appwrite.js`, damit Node es laden kann):
- `createMfaApi(client) → { createEmailChallenge(): Promise<string /*challengeId*/>, completeChallenge(challengeId, otp): Promise<void> }` – über `client.call(method, new URL(client.config.endpoint + '/account/mfa/challenges'), { 'content-type': 'application/json', 'X-Appwrite-Locale': 'de' }, params)`.
- `isMoreFactorsError(err) → boolean` (`err?.type === 'user_more_factors_required'`)
- `mfaErrorMessage(err) → string` (Texte aus Global Constraints; Fallback `err.message || 'Anmeldung fehlgeschlagen'`)
- `maskEmail(email) → string` (gleiche Regel wie Portal)
- AuthContext-Wert zusätzlich: `mfaPending: null | { challengeId: string | null, email: string }`, `verifyCode(otp) → {success, error?}`, `sendCode() → {success, error?}` (erstmals oder erneut), `cancelMfa() → Promise<void>`. `login()` liefert bei MFA `{ success:false, mfaRequired:true }`.
- [ ] **Step 1: Failing tests** (`tests/mfa.test.mjs`, Fake-Client zeichnet `call`-Argumente auf):
- `createEmailChallenge` → Methode `post`, Pfad endet auf `/account/mfa/challenges`, Params `{ factor:'email' }`, Header `X-Appwrite-Locale: de`, Rückgabe = `$id`.
- `completeChallenge('c1','123456')` → Methode `put`, Params `{ challengeId:'c1', otp:'123456' }`.
- `isMoreFactorsError` true/false; `mfaErrorMessage` für `user_invalid_token`, Code 429, `user_email_not_verified`, sonstige.
- `maskEmail('kenso@webklar.com') === 'k…@webklar.com'`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** `mfa.js` implementieren → `npm test` PASS.
- [ ] **Step 4:** AuthContext:
- `login()`: nach `createSessionWithCleanup` → `account.get()`; `isMoreFactorsError` → `sendCode()` + `mfaPending` setzen → `{ success:false, mfaRequired:true }`.
- `checkUser()`: `isMoreFactorsError` → `mfaPending = { challengeId: null, email: '' }`, **keine** Mail, `user` bleibt `null`, lokale Sitzung **nicht** löschen (anders als bei anderen 401).
- `verifyCode`: `completeChallenge` → `account.get()` → `setUser`, `ensureEmployeeExists`, `mfaPending = null`.
- `cancelMfa`: `account.deleteSession('current')` (Fehler schlucken), `clearAllLocalAppwriteSessions()`, `mfaPending = null`.
- E-Mail für den Hinweis: beim Login aus dem Formular; beim Reload unbekannt → Hinweis ohne Adresse `Wir schicken dir einen Code an deine E-Mail-Adresse.` bis `Code senden` gedrückt ist.
- [ ] **Step 5:** LoginPage: bei `mfaPending` Code-Schritt statt Passwort-Formular (Texte wörtlich; `Code senden` nur wenn `challengeId === null`, sonst Codefeld + `Code erneut senden`); `/login` leitet wie bisher nur bei `user` weiter.
- [ ] **Step 6:** UI-Harness (Scratchpad, esbuild + Playwright, Appwrite per `page.route` nachgebaut, Rezept aus Memory): Checks
- Login ohne MFA → `/tickets`.
- Login mit MFA → Code-Schritt, Hinweis `k…@webklar.com`, **1** Challenge-POST.
- falscher Code → `Code falsch oder abgelaufen.`; richtiger Code → `/tickets`.
- `Code erneut senden` → 2. Challenge-POST.
- Reload mit halber Sitzung → Code-Schritt mit `Code senden`, **0** Challenge-POSTs, keine Weiterleitungsschleife (URL bleibt `/login`, max. 1 `GET /account`).
- `Abbrechen` → `DELETE /account/sessions/current`, Passwort-Formular sichtbar; danach neuer Login möglich, auch wenn der Mock `DELETE /account/sessions` mit 401 beantwortet.
- 429 beim Challenge-POST → Text `Zu viele Versuche. Bitte warte einige Minuten.`
- [ ] **Step 7:** `npx vite build` im Worktree (unter `systemd-run --scope -p MemoryMax=700M -p MemorySwapMax=0`) → Build ok.
- [ ] **Step 8: Commit** `git add src/lib/mfa.js tests/mfa.test.mjs src/lib/appwrite.js src/context/AuthContext.jsx src/pages/LoginPage.jsx package.json && git commit -m "Login mit E-Mail-Code (Appwrite-2FA)"`
### Task 7: Seitenleisten-Eintrag „Projekt-Admin“
**Files:**
- Modify: `src/components/Navbar.jsx`, `src/lib/employeeAdminApi.js` (oder neue kleine `src/lib/projectAdminSwitch.js` im gleichen Stil)
**Interfaces:**
- Consumes: `POST /api/admin/switch-link` (Task 4).
- Produces: `openProjectAdmin() → Promise<void>` – `account.createJWT()`, POST, `window.location.assign(openUrl)`; bei Fehler `window.location.assign('https://project.webklar.com/login.html')`.
- [ ] **Step 1:** Eintrag als `<button className="side-link">` (kein `<Link>`, externes Ziel) nach der Linkliste in derselben `side-group`, Icon `IconExternalLink`, Label `Projekt-Admin`, während des Ladens `disabled`; im eingeklappten Zustand `title` gesetzt wie die anderen.
- [ ] **Step 2:** UI-Harness-Checks: Eintrag für Mitarbeiter ohne Admin-Rolle sichtbar; Klick → genau 1 POST auf `https://project.webklar.com/api/admin/switch-link` mit `Authorization: Bearer …`, danach Navigation auf `openUrl`; Fehlerantwort 500 → Navigation auf `…/login.html`.
- [ ] **Step 3:** `npm test` + `vite build` ok.
- [ ] **Step 4: Commit** `git add src/components/Navbar.jsx src/lib/employeeAdminApi.js && git commit -m "Seitenleiste: direkt ins Projekt-Admin wechseln"`
## Teil C – Livegang (nur mit OK des Nutzers)
### Task 8: Code-Mail-Vorlage + Übergabe
**Files:**
- Create: `docs/superpowers/specs/2026-09-30-mfa-mailvorlage.html` (Ticketsystem-Worktree) – Betreff `Dein Anmeldecode für WEBklar`, HTML im Stil der Portal-Vorlagen (`dev-mailvorlagen`/`server/mail-templates`), Platzhalter `{{otp}}`, Text `Der Code gilt 1 Stunde.` und `Nicht du? Dann ändere bitte dein Passwort.`
- [ ] **Step 1:** Vorlage erstellen, lokal mit `{{otp}} = 123456` rendern, Screenshot an den Nutzer.
- [ ] **Step 2:** Übergabe an den Nutzer mit genauer Reihenfolge:
1. Portal: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-2fa push origin feature/mitarbeiter-2fa:main` – danach Live-Check: `POST /api/admin/switch-link` ohne JWT → 401, Login-Seite lädt.
2. Ticketsystem: `git -C /home/webklar/apps/ticket.webklar.com/dev-2fa push origin feature/mitarbeiter-2fa:test` – danach Live-Check Bundle enthält `Projekt-Admin`.
3. Nutzer hinterlegt Vorlage in der Appwrite-Console (Auth → Templates → MFA-Challenge, Sprache Deutsch).
4. `--status`, dann `--enable <Kensos userId>`, Nutzer probiert beide Logins + Wechsel, dann `--enable alle`.
- [ ] **Step 3:** Commit der Vorlage (`git add docs/superpowers/specs/2026-09-30-mfa-mailvorlage.html`).
@@ -0,0 +1,148 @@
# WOMS „Rechtliches“ Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Interner Menüpunkt „Rechtliches“ mit versionierten Rechtstexten und Anbieter-Verträgen samt Fristen.
**Architecture:** Drei neue Appwrite-Sammlungen + ein Bucket, angelegt per idempotentem Setup-Skript. Reine
Fristen-/Versionslogik in `src/lib/legal.js` (Node-Unit-Tests), Datenzugriff in `src/hooks/useLegal.js`,
Oberfläche in `src/pages/LegalPage.jsx` + zwei Reiter-Komponenten. Erstbefüllung per Seed-Skript nach Freigabe
der Versionsliste durch den Nutzer.
**Tech Stack:** React 18, Vite 5, Appwrite JS SDK 13 (Client) / node-appwrite (Skripte), `node --test` (Node 24), Playwright (Scratchpad).
**Spec:** `docs/superpowers/specs/2026-09-30-rechtliches-design.md`
## Global Constraints
- Arbeitsort: Worktree `/home/webklar/apps/ticket.webklar.com/dev-rechtliches`, Branch `feature/rechtliches`. **Nie pushen** – jeder Push (egal welcher Branch) geht live. Push nur durch/nach OK des Nutzers: `git push origin feature/rechtliches:test`.
- `node_modules` ist in Git getrackt: immer `git add <dateien>`, nie `-A`.
- Keine neuen npm-Abhängigkeiten.
- Oberflächentexte Deutsch. Datumsangaben als `YYYY-MM-DD`-String gespeichert, angezeigt `dd.mm.yyyy`.
- Versionen und Dateien: kein Löschen (weder UI noch Rechte). Anbieter-Verträge: Löschen erlaubt.
- Fristenfarben: rot ≤ 30 Tage, orange ≤ 90 Tage bis Kündigungstermin; grau = gekündigt/beendet; „Fristen fehlen“ bei unvollständigen Angaben.
- Keine erfundenen Fristen/Preise im Seed.
- CSS: kein Bootstrap vorhanden (`.d-flex` etc. existieren nicht), vorhandene Klassen/`ui`-Komponenten nutzen.
## Review Focus
- Abgelöst-Setzen scheitert nach dem Anlegen der neuen Version (zweiter Request 401/Netz) → zwei „gültige“ Versionen; UI muss den Fehler zeigen und `currentVersion()` die mit dem jüngsten `validFrom` nehmen (Test Task 1).
- Vertrag mit Start am 31. eines Monats → Monatsende klemmen, nie in den Folgemonat überlaufen (Test Task 1).
- Kündigungstermin genau heute → zählt noch als aktuelle Periode, rot, nicht übersprungen (Test Task 1).
- Neue Version mit `validFrom` älter als die aktuelle (Nachtrag einer alten Fassung) → darf die aktuelle NICHT ablösen, wird als `superseded` mit passendem `validTo` einsortiert (Test Task 1).
- Bucket/Sammlung fehlt noch (Setup nicht gelaufen) → Seite zeigt leeren Zustand mit Hinweis statt Absturz (Test Task 5).
---
### Task 1: Fristen- und Versionslogik
**Files:**
- Create: `src/lib/legal.js`
- Create: `tests/legal.test.mjs`
- Modify: `package.json` (Script `"test": "node --test tests/"`)
**Interfaces:**
- Produces:
- `addMonthsClamped(date: string, n: number): string`
- `nextCancellationDate(contract, today: string): { state: 'active'|'anytime'|'ends'|'ended'|'cancelled'|'incomplete', noticeBy: string|null, periodEnd: string|null }`
- `deadlineTone(result, today: string): 'red'|'orange'|'normal'|'grey'|'missing'`
- `currentVersion(versions): version|null` (Status `current`, bei mehreren das jüngste `validFrom`)
- `planNewVersion(versions, input): { create: object, updates: Array<{ id: string, patch: object }> }`
- `formatDate(date: string): string` (`YYYY-MM-DD` → `dd.mm.yyyy`, leer → `–`)
- [ ] **Step 1: Failing tests** in `tests/legal.test.mjs` (`node:test`, `assert/strict`):
- `addMonthsClamped('2026-01-31', 1) === '2026-02-28'`; `('2028-01-31', 1) === '2028-02-29'`; `('2026-03-15', 12) === '2027-03-15'`.
- Start `2025-03-12`, term 12, renewal 12, notice 1 month, today `2026-09-30` → `{state:'active', periodEnd:'2027-03-12', noticeBy:'2027-02-12'}`.
- gleiche Daten, today `2027-02-12` → noticeBy `2027-02-12` (heute zählt noch); today `2027-02-13` → periodEnd `2028-03-12`, noticeBy `2028-02-12`.
- notice 30 days, periodEnd `2027-03-12` → noticeBy `2027-02-10`.
- `termMonths: 0` → `state:'anytime'`, noticeBy null.
- renewal 0, periodEnd in Zukunft → `state:'ends'`, periodEnd gesetzt; periodEnd vorbei → `'ended'`.
- `cancelledAt` gesetzt → `'cancelled'`, periodEnd = `endsAt`.
- fehlendes `startDate` oder `noticeValue` (bei term > 0) → `'incomplete'`.
- `deadlineTone`: noticeBy in 30 Tagen → `'red'`, 31 → `'orange'`, 90 → `'orange'`, 91 → `'normal'`; cancelled/ended → `'grey'`; incomplete → `'missing'`; anytime/ends → `'normal'`.
- `planNewVersion` mit aktueller v5 (validFrom `2026-05-01`), Eingabe v6 status `current` validFrom `2026-09-01` → updates `[{id:v5.$id, patch:{status:'superseded', validTo:'2026-08-31'}}]`, create.validTo null.
- Eingabe status `draft` → updates leer.
- Eingabe `current` mit validFrom `2025-01-01` (älter als v5) → create.status `'superseded'`, create.validTo = Tag vor dem nächstjüngeren validFrom (`2026-04-30`), updates leer.
- `currentVersion` mit zwei `current` → die mit jüngerem validFrom.
- [ ] **Step 2:** `npm test` → FAIL (Modul fehlt).
- [ ] **Step 3: Implementieren.** Rein, ohne Imports; Datumsrechnung in UTC auf `YYYY-MM-DD`. Algorithmus `nextCancellationDate` (active): `periodEnd = addMonthsClamped(start, term)`; solange `noticeBy(periodEnd) < today`: `periodEnd = addMonthsClamped(periodEnd, renewal)`. Notice in Monaten per `addMonthsClamped(periodEnd, -n)`, in Tagen per Tagesabzug.
- [ ] **Step 4:** `npm test` → alle PASS.
- [ ] **Step 5: Commit** `git add src/lib/legal.js tests/legal.test.mjs package.json && git commit -m "Rechtliches: Fristen- und Versionslogik mit Tests"`
### Task 2: Appwrite-Setup und Konstanten
**Files:**
- Create: `scripts/setup-legal.mjs`
- Modify: `src/lib/appwrite.js` (`Storage` exportieren, Konstanten)
**Interfaces:**
- Produces: `COLLECTIONS.LEGAL_DOCUMENTS = 'legalDocuments'`, `COLLECTIONS.LEGAL_VERSIONS = 'legalVersions'`, `COLLECTIONS.PROVIDER_CONTRACTS = 'providerContracts'`, `LEGAL_BUCKET_ID = 'legal-files'`, `storage` (Appwrite `Storage`).
- [ ] **Step 1: Skript schreiben** nach Muster `setup-woms-appwrite.mjs` (ENV `APPWRITE_API_KEY`, idempotent, vorhandene Attribute überspringen). Attribute exakt wie Spec-Abschnitt „Daten“; Datumsfelder `string(10)`, `fileIds` string-Array (size 64), `changeNote`/`note` string(5000), `status`/`costInterval`/`noticeUnit` als enum, `cost` float, Monats-/Fristzahlen integer. Rechte: `legalDocuments`/`legalVersions` = read/create/update für `users`, **kein delete**; `providerContracts` = read/create/update/delete `users`. Bucket `legal-files`: read/create `users`, kein update/delete, `maximumFileSize` 20 MB, Endungen `pdf, html, htm, png, jpg, jpeg, webp, doc, docx`. Die 7 `legalDocuments` (Keys `agb, abo-bedingungen, avv, datenschutz-website, datenschutz-portal, impressum, widerruf`, mit Titel, Live-URL, sort 1–7) als feste IDs = Key anlegen, wenn fehlend. Flag `--dry-run` gibt nur aus, was angelegt würde.
- [ ] **Step 2:** `node --check scripts/setup-legal.mjs` → OK; `node scripts/setup-legal.mjs --dry-run` ohne Key → listet Sammlungen/Bucket/Dokumente, Exit 0.
- [ ] **Step 3:** Konstanten in `appwrite.js`; `npm run build` → Exit 0.
- [ ] **Step 4: Commit.** Skript wird erst in Task 6 mit Key ausgeführt.
### Task 3: Daten-Hook
**Files:**
- Create: `src/hooks/useLegal.js`
**Interfaces:**
- Consumes: Task 1 `planNewVersion`, Task 2 Konstanten.
- Produces:
- `useLegalDocuments(): { documents, versions, loading, error, missingSetup: boolean, addVersion(documentKey, input, files: File[]), updateVersion(id, patch), refresh }`
- `useProviderContracts(): { contracts, loading, error, missingSetup, saveContract(data, files, id?), deleteContract(id), refresh }`
- `fileUrl(fileId): string` (View-URL), `fileDownloadUrl(fileId): string`
- [ ] **Step 1: Implementieren.** Listen mit `Query.limit(5000)`. 404 auf Sammlung → `missingSetup: true`, leere Listen, kein Fehler. `addVersion`: Dateien per `storage.createFile(LEGAL_BUCKET_ID, ID.unique(), file)`, dann `planNewVersion` → erst `createDocument`, dann die `updates`; scheitert ein Update, `error` = „Neue Version gespeichert, alte Version konnte nicht abgelöst werden: …“ und trotzdem `refresh()`.
- [ ] **Step 2:** `npm run build` → Exit 0. (Verhalten wird in Task 5 per Playwright geprüft.)
- [ ] **Step 3: Commit.**
### Task 4: Seite „Rechtliches“ mit beiden Reitern
**Files:**
- Create: `src/pages/LegalPage.jsx`, `src/components/legal/LegalTextsTab.jsx`, `src/components/legal/VersionForm.jsx`, `src/components/legal/ProviderContractsTab.jsx`, `src/components/legal/ContractForm.jsx`
- Modify: `src/App.jsx` (Route `/rechtliches` in `ProtectedRoute`), `src/components/Navbar.jsx` (Eintrag „Rechtliches“ mit `IconScale` nach „Finanzen“)
**Interfaces:**
- Consumes: Task 1 (`currentVersion`, `nextCancellationDate`, `deadlineTone`, `formatDate`), Task 3 Hooks.
- [ ] **Step 1: LegalTextsTab.** Pro Dokument `Card`: Titel, aktuelle Version + „gilt seit“, Link „Live-Seite“ (`target=_blank`); Entwürfe als graues `Badge` „Entwurf“. Klick klappt Versionsgeschichte auf (neu → alt nach validFrom): Nummer, `formatDate(validFrom)`–`formatDate(validTo)`, Notiz, Dateilinks (Ansehen/Herunterladen). Aktuelle Zeile grün. Button „Neue Version“ öffnet `VersionForm` (Felder: Version, Status draft/current, gültig ab, Notiz, Dateien; Pflicht: Version, gültig ab). Entwurf-Zeilen haben „Als gültig markieren“ (setzt `status current` über `planNewVersion`-Logik: Update + Ablösung). Kein Löschen-Button.
- [ ] **Step 2: ProviderContractsTab.** Tabelle/Liste sortiert: rot, orange, normal, missing, grey; innerhalb nach noticeBy. Spalten: Anbieter, Zweck, Kosten (`12,00 €/Monat`), Laufzeit, Kündigungsfrist, nächster Kündigungstermin (bzw. „jederzeit kündbar“, „endet am …“, „gekündigt zum …“, „Fristen fehlen“). Zeilenfarbe per Klasse `legal-tone-{tone}`. Detail aufklappbar mit Notiz, Dateien, „Bearbeiten“, „Als gekündigt markieren“ (fragt `endsAt`, setzt `cancelledAt` = heute), „Löschen“ mit `confirm`. `ContractForm` für Neu/Bearbeiten.
- [ ] **Step 3: LegalPage** mit `PageHeader` „Rechtliches“ und `Tabs` (`texte` „Unsere Rechtstexte“, `anbieter` „Verträge mit Anbietern“). Bei `missingSetup` `EmptyState` „Rechtliches ist noch nicht eingerichtet (Setup-Skript fehlt).“ CSS-Regeln für `legal-tone-*` in die bestehende globale CSS.
- [ ] **Step 4:** `npm run build` → Exit 0.
- [ ] **Step 5: Commit.**
### Task 5: Oberflächentest
**Files:**
- Create (Scratchpad, nicht im Repo): `legal-e2e.mjs` – Vite-Build mit `DIST=…`, Appwrite-Mock unter `https://ticket.test` (Muster `appsmoke.mjs` aus Memory „woms-ticketsystem“), Build und Browser unter `systemd-run --scope -p MemoryMax=…`.
- [ ] **Step 1: Test schreiben**, Prüfungen:
- Menü zeigt „Rechtliches“, `/rechtliches` lädt, zwei Reiter.
- AGB-Karte zeigt aktuelle „v5“, grün; Geschichte listet v5, v4 mit `validTo`.
- „Neue Version“ v6 current ab heute → Mock erhält Create v6 **und** Patch v5 `{status:'superseded', validTo:<gestern>}`; Karte zeigt v6.
- Patch-Request mit 401 gemockt → Fehlermeldung sichtbar, v6 trotzdem gelistet.
- Kein „Löschen“ bei Versionen.
- Anbieter-Reiter: Vertrag mit noticeBy in 10 Tagen hat `legal-tone-red` und steht oben; unvollständiger zeigt „Fristen fehlen“; gekündigter ist grau und unten.
- Sammlung liefert 404 → EmptyState-Hinweis, kein Absturz.
- [ ] **Step 2:** Test laufen lassen → alle PASS; bei FAIL Code in Task 3/4 korrigieren und neu committen.
### Task 6: Einrichtung und Erstbefüllung (Nutzer-Gates)
**Files:**
- Create: `scripts/seed-legal.mjs`, `scripts/legal-seed.json` (erzeugte Versionsliste)
- [ ] **Step 1: Versionsliste rekonstruieren.** Pro Text: `git log --format='%h %ad %s' --date=short origin/main -- <datei>` in `/home/webklar/apps/webklar.com/build` (`src/pages/AGB.tsx`, `Impressum.tsx`, `Datenschutz.tsx`) und `/home/webklar/apps/kundenbereich.webklar.com/build` (`public/abo-bedingungen.html` inkl. AVV-Anlage, `public/datenschutz.html`, `public/widerrufen.html`). Nur inhaltliche Änderungen werden Versionen (reine Format-/Build-Commits zusammenfassen). Ergebnis als `legal-seed.json`: `{documentKey, version, validFrom, status, changeNote, source: '<repo>@<hash>:<pfad>'}`. Nicht auf main liegende Stände (z. B. Abo-Bedingungen v4, AGB `f40e889`) als `draft`.
- [ ] **Step 2: GATE – Nutzer prüft die Liste** (Nummerierung, v. a. AGB „v6“). Erst nach OK weiter.
- [ ] **Step 3: Seed-Skript.** Liest JSON, erzeugt je Version eine Datei: Portal-HTML direkt aus `git show`; Website-TSX als lesbares HTML (Text aus JSX, eigene Mini-Umwandlung; bei Unsicherheit TSX-Quelltext als `.html` in `<pre>`). Upload in `legal-files`, Dokument in `legalVersions`. Idempotent über `source` (schon vorhanden → überspringen). Anbieter Hetzner (Server), Hetzner (Mail), Porkbun (Domains), Stripe (Zahlungen) ohne Fristen/Preise. `--dry-run`.
- [ ] **Step 4: GATE – API-Key.** Key auf dem Server suchen (nur Dateien, die bisherige Setup-Läufe nutzten); sonst Nutzer fragen. Dann `node scripts/setup-legal.mjs`, danach `node scripts/seed-legal.mjs` → Ausgabe zählt angelegte Sammlungen/Versionen.
- [ ] **Step 5: Commit** Skripte + JSON.
### Task 7: Abschluss
- [ ] **Step 1:** `npm test`, `npm run build`, Task-5-Test → grün. Deploy-nahe Prüfung: `git archive` + `docker run node:20-alpine` (`npm install; npm run build`).
- [ ] **Step 2: GATE – Push-OK des Nutzers**, dann `git push origin feature/rechtliches:test`; Live-Bundle auf „Rechtliches“ prüfen.
- [ ] **Step 3:** Memory `woms-ticketsystem` aktualisieren.
@@ -0,0 +1,183 @@
# Serverpuls Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Admin-only WOMS page „Serverpuls“ with server/container/app metrics over time and deterministic Low/Medium/Critical mail alerts from a small monitoring service.
**Architecture:** New service `/home/webklar/apps/serverpuls` (own local git repo, Node 22, `node:sqlite`, nodemailer) collects from host `/proc`, a read-only docker-socket-proxy and read-only mounts, stores downsampled series in SQLite, evaluates rules, mails via server@webklar.com and serves a read-only JWT+admin API. WOMS nginx proxies `/api/serverpuls/`; WOMS gets `ServerpulsPage` with uPlot charts.
**Tech Stack:** Node 22 (container) / Node 24 (host tests, `node --test`), `node:sqlite`, nodemailer, `tecnativa/docker-socket-proxy`, React 18 + Vite 5, uPlot, Playwright headless shell (host cache `chromium_headless_shell-1234`).
**Spec:** `docs/superpowers/specs/2026-09-30-serverpuls-design.md` (WOMS worktree `dev-serverpuls`, branch `feature/serverpuls`).
## Global Constraints
- No AI anywhere in collection, rules or mails.
- serverpuls container: `mem_limit: 128m`, `cpus: 0.5`, `init: true`, port 8091, base path `/api/serverpuls`.
- Every API route except `/health` requires `X-Appwrite-JWT`; `/account` must return `labels` containing `admin` → else 401 (no/invalid JWT) / 403 (no admin). Auth cache 60 s.
- API is read-only: only `GET`. No delete/stop/restart anywhere.
- Mail: `mail.your-server.de:587` STARTTLS, user + from + to = `server@webklar.com`; password only in `/home/webklar/apps/.env` as `SERVERPULS_SMTP_PASS`, set by the user; never printed, never read by Claude.
- Retention: raw 5 min for 7 days → hourly (avg + max) for 90 days → daily forever. Sizes daily forever.
- Schedules (Europe/Berlin, via `Intl`): sample every 5 min; sizes 03:00; downsampling 03:30; Medium digest daily 07:00; Low weekly Monday 07:00. Due-state stored in SQLite `meta` so restarts neither skip nor double-send.
- Missing measurement = no row (gap), never 0.
- Critical containers: `webklar-traefik`, `webklar-appwrite`, `webklar-kundenbereich-server`, `webklar-ticket`, `webklar-ticket-integrations`, `webklar-gitea`, `webklar-invoiceninja`, `webklar-n8n`.
- Mail subjects start with `[Serverpuls]`; texts German.
- WOMS: never push; never `git add -A` (node_modules is tracked); branch `feature/serverpuls`. Live = the user pushes to `test`.
- Heavy work (vite build, Playwright, npm install) runs under `systemd-run --scope -p MemoryMax=…M -p MemorySwapMax=0` (build 700M, browser 700M) — swap is full.
- Before editing `/home/webklar/apps/docker-compose.yml`, `.env`-touching scripts or the WOMS nginx conf: backup `*.bak-2026-09-30-serverpuls`.
## Review Focus
- Small apps flapping in growth rules (1 MB → 2 MB = +100 %): percentage growth rules only apply when the app is ≥ 100 MB at the later point (absolute 2 GB rule always applies). Test in Task 3.
- First days without 7-day history: growth rules silent until a size row ≥ 6 days old exists (no division by missing/zero). Test in Task 3.
- Service restart / downtime around 07:00: digest is sent once, on the first tick after 07:00 the same day; never twice. Test in Task 4.
- Container that is deliberately stopped (e.g. Paperless, exit code 0): no alert; only non-zero exit or restart loop counts for non-critical. Test in Task 3.
- API polled by the page every minute while Appwrite is slow: auth cache prevents one `/account` call per request; Appwrite down → 502 with JSON `{error}`, page shows the error instead of blank charts. Tests in Task 5 and Task 8.
---
## File Structure
Service repo `/home/webklar/apps/serverpuls/`:
| File | Responsibility |
|---|---|
| `Dockerfile` | `FROM node:22-alpine`, `apk add --no-cache util-linux-misc` (ionice), `npm ci --omit=dev`, `CMD node src/main.js` |
| `package.json` | `"type":"module"`, dep `nodemailer`, script `test: node --test test/` |
| `config/apps.json` | container → app grouping, app → folders |
| `config/rules.json` | thresholds + critical container list |
| `src/collect/host.js` | parse `/proc` + statfs |
| `src/collect/docker.js` | proxy client: list, inspect, stats, system df |
| `src/collect/sizes.js` | nightly `du` per app + previews + cleanup numbers |
| `src/apps.js` | map containers/volumes/folders to apps |
| `src/store.js` | SQLite schema, inserts, queries, downsampling, meta |
| `src/rules.js` | pure rule evaluation |
| `src/alerts.js` | state diff, mail decisions, digests |
| `src/mail.js` | nodemailer + queue |
| `src/api.js` | HTTP API + auth |
| `src/schedule.js` | Berlin-time due checks |
| `src/main.js` | wiring + timers |
| `test/*.test.js`, `test/fixtures/` | unit/API tests |
WOMS worktree `dev-serverpuls`: `src/lib/serverpulsApi.js`, `src/lib/serverpulsFormat.js`, `src/components/serverpuls/{Chart.jsx,Sparkline.jsx,AppsTable.jsx}`, `src/pages/ServerpulsPage.jsx`, `src/styles/serverpuls.css`; modify `src/App.jsx`, `src/components/Navbar.jsx`, `package.json`, `package-lock.json`; test `tests/unit/serverpulsFormat.test.mjs`.
Ops: `/home/webklar/apps/docker-compose.yml`, `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf`, `/home/webklar/apps/scripts/mail-zugang.py`.
---
### Task 1: Repo, host collector, app mapping
**Files:**
- Create: `Dockerfile`, `package.json`, `.gitignore` (`node_modules`, `data`), `config/apps.json`, `src/collect/host.js`, `src/apps.js`
- Test: `test/host.test.js`, `test/apps.test.js`, fixtures `test/fixtures/proc/{stat,stat2,meminfo,loadavg}` and `test/fixtures/proc/{101,102,103}/stat` (103 in state `Z`)
**Interfaces:**
- Produces: `readHost(procRoot: string, diskPath: string, prevCpu?: {idle,total}) → Promise<{cpuPct: number|null, cpuRaw:{idle,total}, load1, load5, load15, memAvail, memTotal, swapUsed, swapTotal, diskUsed, diskTotal, zombies}>` (bytes; `cpuPct` null when no `prevCpu`).
- Produces: `loadAppsConfig(path) → AppsConfig`; `appForContainer(cfg, {name, service, project}) → string`; `foldersForApp(cfg, app) → string[]` (relative to apps root).
- [ ] **Step 1:** `git init` in `/home/webklar/apps/serverpuls`; package.json, Dockerfile, .gitignore. Install nodemailer + lockfile in a throwaway `docker run --rm -v $PWD:/w -w /w node:22-alpine npm install nodemailer` under MemoryMax 700M.
- [ ] **Step 2: Failing tests** — `host.test.js`: with fixtures `stat`→`stat2` delta, `cpuPct` equals the hand-computed value (write it into the fixture comment); `memAvail` = MemAvailable×1024; `swapUsed` = (SwapTotal−SwapFree)×1024; `zombies === 1`; unreadable `/proc/<pid>/stat` (pid dir without stat) is skipped, not thrown. `apps.test.js`: `webklar-appwrite-worker-mails` → `Appwrite`; `openruntimes-executor` → `Appwrite`; `webklar-nextcloud25-db` → `Nextcloud 25`; `webklar-ticket`, `webklar-ticket-build`, `webklar-ticket-integrations` → `Ticketsystem`; `debian-app-1` (project `debian`) → `debian`; unknown `webklar-foo` → `foo`.
- [ ] **Step 3:** Run `node --test test/` → FAIL.
- [ ] **Step 4:** Implement. `config/apps.json` shape: `{"groups":[{"app":"Appwrite","match":["webklar-appwrite*","openruntimes-executor"],"folders":["appwrite"]}, …]}`, glob `*` only; fallback = container name minus `webklar-` prefix, or compose project for non-`apps` projects. Seed groups for Appwrite, Kundenportal (`webklar-kundenbereich-*`, folder `kundenbereich.webklar.com`), Ticketsystem (`ticket.webklar.com`, `ticket-integrations`), Website (`webklar-webclaw*`, `webklar.com`), Nextcloud, Nextcloud 25, InvoiceNinja (`InvoiceNinja`), Gitea, n8n, Bitwarden, E-Mail-Sorter (`emailsorter*`), eship, Previews-System (`webklar-preview-*`, `previews`), Traefik. Disk via `fs.statfsSync(diskPath)`.
- [ ] **Step 5:** `node --test test/` → PASS. Commit `serverpuls: host collector + app mapping`.
### Task 2: Store and downsampling
**Files:** Create `src/store.js`; Test `test/store.test.js`
**Interfaces:**
- Produces: `openStore(file: string) → Store` with
`insertHost(ts, host)`, `insertApps(ts, [{app, cpuPct, memBytes}])`, `insertContainers(ts, [{name, app, status, health, exitCode, restartCount}])`, `insertSizes(day 'YYYY-MM-DD', [{app, bytes}])`, `insertCleanup(day, {imagesBytes, buildCacheBytes, stoppedBytes, stopped:[{name,bytes}]})`,
`hostSeries(metric, sinceTs, res: 'raw'|'hour'|'day') → [{ts, v, max}]`, `appSeries(app, field: 'cpu'|'mem', sinceTs, res)`, `sizeSeries(app|null, sinceDay) → [{day, app, bytes}]`, `containerHistory(name, sinceTs)`, `latestHost()`, `latestContainers()`, `latestCleanup()`,
`downsample(nowTs)`, `getMeta(key)`, `setMeta(key, value)`, `alertState() → Map`, `saveAlertState(Map)`, `queueMail(msg)`, `pendingMails()`, `markMailSent(id)`, `dropMailsOlderThan(ts)`.
- Metrics names: `cpu, load1, load5, load15, memAvail, memTotal, swapUsed, swapTotal, diskUsed, diskTotal, zombies`.
- [ ] **Step 1: Failing tests** (`:memory:` DB): insert 24 raw rows 5 min apart at 10 days ago → `downsample(now)` leaves 0 raw, 2 hour rows with correct avg and max; hour rows older than 90 days become day rows; raw from 1 day ago untouched; `containerHistory` rows older than 7 days deleted; `setMeta/getMeta` round-trip; alert state round-trip survives reopen of a temp file DB.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement (tables per spec; `host_samples(ts, res, metric…, *_max)` one row per ts; indexes on `(res, ts)`; `app_samples(ts,res,app,cpu,cpu_max,mem,mem_max)`). **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: sqlite store + downsampling`.
### Task 3: Rules
**Files:** Create `config/rules.json`, `src/rules.js`; Test `test/rules.test.js`
**Interfaces:**
- Consumes: Store queries (Task 2) via an input object built in `main.js`.
- Produces: `evaluate(input, rules) → Finding[]` where
`input = {now, hostRaw:[{ts, …metrics}] (last 2 h), containers:[{name, app, status, health, exitCode, restartCount}], restarts1h: {name: n}, sizes:[{day, app, bytes}] (last 8 days), cleanup, zombieSince: ts|null, critical: string[]}` and
`Finding = {id: string, level: 'low'|'medium'|'critical', title: string, value: string}`.
- Ids: `disk`, `mem`, `load`, `zombies`, `zombies-persist`, `container:<name>`, `growth:<app>`, `cleanup`.
- [ ] **Step 1: Failing tests** — one per spec rule and both sides of each threshold: disk 89.9 % → medium, 90 % → critical; mem critical needs every sample in last 10 min to have memAvail < 500 MB **and** swap ≥ 90 % (and ≥ 2 samples); one good sample inside window → not critical; swap ≥ 75 % for 1 h → medium; load5 > 24 for 15 min → critical, > 8 for 1 h → medium; zombies 100 → critical, 20 → medium, ≥ 1 for > 24 h → low `zombies-persist`; critical container `exited` → critical; any container restartCount +4 in 1 h → critical if in list else medium; non-critical exited with exitCode 0 → none, exitCode 1 → medium; growth 7 d +21 % on 500 MB app → medium; +21 % on 50 MB app → none; +2.1 GB on 50 GB app → medium; +11 % week on 500 MB → low; only 3 days of sizes → no growth finding; images 11 GB → low `cleanup`. Each finding has non-empty German `title` and `value`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement with values from `config/rules.json` (copy the spec numbers verbatim; `minGrowthPctBaseBytes: 104857600`). One finding per id, highest level wins. **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: warning rules`.
### Task 4: Alerts, schedule, mail
**Files:** Create `src/schedule.js`, `src/alerts.js`, `src/mail.js`; Test `test/alerts.test.js`, `test/schedule.test.js`
**Interfaces:**
- Produces: `berlinParts(ts) → {day:'YYYY-MM-DD', hour, minute, weekday: 1..7}`; `isDue(kind: 'sizes'|'downsample'|'daily'|'weekly', ts, lastRunDay) → boolean` (03:00 / 03:30 / 07:00 / Monday 07:00; due once per day/week after the time).
- Produces: `processFindings(findings, state: Map, now) → {state, immediate: Mail[], resolvedForDigest: {medium:[], low:[]}}` — state entry `{id, level, since, lastMailed}`; critical new or re-mail after 24 h or escalation → immediate; critical resolved → immediate „✅ behoben“; medium/low resolved → collected for next digest.
- Produces: `dailyDigest(state, resolved, now) → Mail|null` (null if nothing active or resolved), `weeklyDigest(state, resolved, tops, now) → Mail` (always, contains the three Top-5 lists); `Mail = {subject, text, html}`.
- Produces: `createMailer(env) → {send(mail) → Promise}`; `flushQueue(store, mailer, now)` retries every 5 min, drops after 24 h; empty `SERVERPULS_SMTP_PASS` → mails stay queued, one log line, no crash.
- [ ] **Step 1: Failing tests** — new critical → 1 immediate, subject `[Serverpuls] 🔴 Critical: <title>`; same finding again 1 h later → 0; 24 h later → 1; medium → critical escalation → 1 immediate; critical disappears → `[Serverpuls] ✅ behoben: <title>`; medium only appears in `dailyDigest` subject `[Serverpuls] 🟠 Tagesbericht: n Warnungen`; weekly subject `[Serverpuls] 🟡 Wochenübersicht KW <n>`; `isDue('daily')` at 06:59 Berlin false, 07:00 true, again same day with lastRunDay=today false, service down 06:00–09:00 then tick 09:05 → true once; DST switch day (2026-10-25) still exactly once. `flushQueue` with a failing mailer keeps the mail; with success marks it sent; mailer without password never calls SMTP and keeps the mail.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement; nodemailer `{host, port:587, secure:false, requireTLS:true, auth}` from `SERVERPULS_SMTP_*`; mails text + simple HTML table, link `https://ticket.webklar.com/serverpuls`. **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: alerts, digests, mail queue`.
### Task 5: API
**Files:** Create `src/api.js`; Test `test/api.test.js`
**Interfaces:**
- Consumes: Store (Task 2), active alert state.
- Produces: `createApi({store, getAlerts: () => Finding-state[], appwrite: {endpoint, projectId}, fetchImpl}) → http.Server` with routes from the spec table. Range → resolution: `24h`,`7d` raw; `30d`,`90d` hour; `1y` day. `/overview` returns `{lastSample, host: {metric: {value, spark24h:[…], deltaYesterday}}, alerts:[…], level: 'ok'|'medium'|'critical'}`; `/apps` → `{sizesDay, rows}` (sizesDay = date of the latest size measurement, shown on the page), rows `{id, name, cpuPct, memBytes, sizeBytes, delta7d, delta30d, spark30d}` (deltas null without history); `/apps/:id` `{size, mem, cpu}` series; `/cleanup` = latest cleanup row.
- [ ] **Step 1: Failing tests** (seeded `:memory:` store, fake `fetchImpl` for `/account`): no JWT → 401; JWT with `labels:[]` → 403; admin → 200 on every route; `/host?metric=bogus` → 400; `/host?range=30d` returns hour rows; `/apps/unknown` → 404; POST → 405; `/account` throws → 502 `{error}`; two requests with same JWT within 60 s → one `/account` call; `/health` without JWT → 200.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement (plain `node:http`, pattern of `ticket-integrations/server.js`). **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: read-only admin API`.
### Task 6: Docker + sizes collectors, main wiring
**Files:** Create `src/collect/docker.js`, `src/collect/sizes.js`, `src/main.js`; Test `test/docker.test.js`, `test/sizes.test.js`
**Interfaces:**
- Produces: `dockerClient(baseUrl) → {list(), inspect(id), stats(id), systemDf()}`; `containerCpuPct(stats) → number|null` (standard `cpu_delta/system_delta*online_cpus*100`); `memBytes(stats)` = usage − inactive_file (fallback cache).
- Produces: `measureSizes({appsRoot, volumesRoot, previewsRoot, apps: [{app, folders, volumes}], timeoutMs: 1800000}) → Promise<[{app, bytes}]>` using `ionice -c3 nice -n 19 du -sk` (fallback `nice` only if ionice missing); each preview folder → app `Preview: <name>`.
- `main.js`: env `DB_FILE=/data/serverpuls.db`, `PROC_ROOT=/host/proc`, `DISK_PATH=/host/apps`, `APPS_ROOT=/host/apps`, `VOLUMES_ROOT=/host/volumes`, `PREVIEWS_ROOT=/host/previews`, `DOCKER_URL=http://serverpuls-docker-proxy:2375`; 60 s tick: sample if ≥ 5 min since last; due jobs; rules → alerts → queue → flush. Every collector failure is caught and logged; others still stored.
- [ ] **Step 1: Failing tests** — `containerCpuPct` on a fixture stats JSON equals hand value; missing `precpu_stats` → null; `measureSizes` on a temp tree (folder A 3 files, volume dir, previews/x) returns the three apps with sizes ≥ file bytes; a `du` that exceeds `timeoutMs` (set 1) rejects/returns partial without throwing out of `measureSizes`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement. **Step 4:** Run all `node --test test/` → PASS. **Step 5:** Commit `serverpuls: docker/sizes collectors + main loop`.
### Task 7: Deploy service (ops, with user gate for mail)
**Files:** Modify `/home/webklar/apps/docker-compose.yml` (backup first), `/home/webklar/apps/scripts/mail-zugang.py` (backup first), `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` (backup first).
- [ ] **Step 1:** Compose: add `serverpuls-docker-proxy` (`tecnativa/docker-socket-proxy`, `CONTAINERS=1 IMAGES=1 SYSTEM=1 POST=0`, socket `:ro`, `mem_limit: 32m`, `webklar-network`, no Traefik labels) and `serverpuls` (`build: ./serverpuls`, `container_name: webklar-serverpuls`, limits from Global Constraints, mounts `/proc:/host/proc:ro`, `./:/host/apps:ro`, `/var/lib/docker/volumes:/host/volumes:ro`, `/var/www/previews:/host/previews:ro`, `serverpuls-data:/data`, env `SERVERPULS_SMTP_USER=server@webklar.com`, `SERVERPULS_SMTP_PASS=${SERVERPULS_SMTP_PASS:-}`, `TZ=Europe/Berlin`). `docker compose config -q` → exit 0. Note: mount `/host/previews` is an addition to the spec's mount list (needed for preview apps).
- [ ] **Step 2:** `docker compose up -d --build serverpuls-docker-proxy serverpuls` (build under MemoryMax 700M). Check: `docker stats --no-stream webklar-serverpuls` < 128 MiB; after 6 min `wget -qO- http://webklar-serverpuls:8091/api/serverpuls/health` from `webklar-ticket-integrations` shows `lastSample`; proxy rejects `POST /containers/x/stop` with 403.
- [ ] **Step 3:** nginx: add `location /api/serverpuls/` block mirroring `/api/integrations/` (`set $serverpuls_upstream webklar-serverpuls; proxy_pass http://$serverpuls_upstream:8091;`, no `client_max_body_size`). `docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload`. `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/serverpuls/overview` → 401.
- [ ] **Step 4:** `mail-zugang.py --serverpuls`: prompt password for server@webklar.com (getpass), test login, backup + write `SERVERPULS_SMTP_PASS` into `.env`, `docker compose up -d serverpuls`, send test mail `[Serverpuls] Testmail` from inside the container. Dry-run with `WEBKLAR_ROOT` copy + fake SMTP. **User gate:** the user runs it; Claude only checks for the `250` result line.
- [ ] **Step 5:** Commit service repo; record compose/nginx/script changes in the memory file.
### Task 8: WOMS page
**Files:** Create `src/lib/serverpulsApi.js`, `src/lib/serverpulsFormat.js`, `src/components/serverpuls/{Chart.jsx,Sparkline.jsx,AppsTable.jsx}`, `src/pages/ServerpulsPage.jsx`, `src/styles/serverpuls.css`; Modify `src/App.jsx` (route `/serverpuls`), `src/components/Navbar.jsx` (entry after „Rechtliches“, before „Admin“, only `isAdmin`, pulse icon, dot from `overview.level`, fetched on app load and every 5 min, admins only), `package.json`/`package-lock.json` (`uplot`); Test `tests/unit/serverpulsFormat.test.mjs`.
**Interfaces:**
- Consumes: API from Task 5.
- Produces: `serverpulsApi.{overview(), host(metric, range), apps(), app(id, range), cleanup()}` (JWT pattern of `integrationsApi.js`, base `/api/serverpuls`); `formatBytes(n) → '1,2 GB'` (de-DE, base 1024), `formatDelta(pct|null) → {text:'+12,3 %'|'–', dir:'up'|'down'|'flat'}`, `ageLabel(ts, now) → {text:'vor 4 Min.', stale: boolean}` (stale > 15 min).
- [ ] **Step 1: Failing test** `serverpulsFormat.test.mjs`: `formatBytes(1288490189)` → `'1,2 GB'`; `formatBytes(0)` → `'0 B'`; `formatDelta(null).text` → `'–'`; `formatDelta(-3.21)` → `{text:'−3,2 %', dir:'down'}`; `ageLabel` at 16 min → stale true, 14 min → false.
- [ ] **Step 2:** `npm test` → FAIL. **Step 3:** Implement format helpers → PASS.
- [ ] **Step 4:** `uplot` into package.json + lockfile via throwaway `docker run node:20-alpine` (never touch tracked node_modules). Implement page per spec „WOMS-Seite“ (ampel, 6 tiles, big chart with range + metric selector, sortable/searchable apps table with row → detail chart, „Aufräumbar“, 60 s refresh while `document.visibilityState==='visible'`, error box on API error, non-admin → `<Navigate to="/tickets">`). Mobile: tiles stack < 700 px.
- [ ] **Step 5:** Deploy-like build: `git archive HEAD` + `docker run --rm node:20-alpine sh -c 'npm install && npm run build'` under MemoryMax 700M → exit 0. Commit `Serverpuls: Admin-Seite mit Verlauf und Warnungen` (only the listed files).
### Task 9: UI test
**Files:** Create (scratchpad, not in repo) `serverpuls-e2e.mjs`.
- [ ] **Step 1:** Vite build with `DIST=…` (MemoryMax 700M); serve via Playwright `page.route` under `https://ticket.test`, mock Appwrite (`/v1/account` with/without `labels:['admin']`) and `/api/serverpuls/*` fixtures (one medium alert, 3 apps, one with `delta7d:null`).
- [ ] **Step 2:** Assertions: admin sees nav entry „Serverpuls“ with orange dot; non-admin sees no entry and `/serverpuls` redirects to `/tickets`; ampel text contains „1 Warnung“; 6 tiles; switching range to „30 T“ requests `range=30d`; clicking column „Δ 7 T“ sorts; null delta shows „–“; row click opens detail chart; `/overview` 502 → error box visible, no crash; `lastSample` 20 min old → stale hint; width 390 px → no horizontal scroll.
- [ ] **Step 3:** Run under MemoryMax 700M → all pass; screenshot desktop + mobile for the user.
### Task 10: Finish
- [ ] **Step 1:** Service: `node --test test/` all pass; live container RAM < 128 MiB after 1 h; first rows in `/overview`.
- [ ] **Step 2:** WOMS: `npm test` all pass; final whole-branch review.
- [ ] **Step 3:** Report to the user: what is live (service + nginx), what waits (WOMS push `git -C dev-serverpuls push origin feature/serverpuls:test`), password step status. Update memory.
@@ -0,0 +1,256 @@
# Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mitarbeiter können auf Arbeitsblättern als Unterstützung (Person + Tätigkeit) eingetragen werden und bekommen 50 % der Arbeitszeit (aufgerundet auf 15 min) angerechnet; Zeiten gibt es überall nur noch in 15-Minuten-Schritten; Kundenportal, Stundenkonto und Verträge ziehen mit.
**Architecture:** Neues JSON-Attribut `supporters` auf `worksheets`; Minuten werden nie gespeichert, sondern mit derselben Rechenregel in WOMS (`src/lib/support.js`) und Portal (`server/services/worksheetMinutes.js`) aus `totalTime` berechnet. WOMS bekommt eine gemeinsame Zeitauswahl (`DurationSelect`) und zwei Wege, Unterstützung einzutragen (Ersteller im Zeiten-Formular, Helfer über „Ich habe unterstützt“). Das Portal zeigt Unterstützung als eigene Zeilen, eine Gesamtzeit oben und zieht sie im Abo-Stundenkonto ab. Abo-Bedingungen Fassung 5 und AGB § 9 Abs. 2 halten die Regeln fest.
**Tech Stack:** WOMS React/Vite + Appwrite Web SDK 13, Tests `node --test` (+ esbuild `tests/unit/load.mjs`), Playwright-Harness; Portal Node/Express ESM, `node --test test/*.test.js`, Vanilla-JS `public/app.js`; webklar.com React/TS + vitest.
**Spec:** `docs/superpowers/specs/2026-09-30-unterstuetzung-design.md` (WOMS-Repo, Commit a336dc0)
## Repos und Arbeitsverzeichnisse
| Teil | Repo / Zweig | Worktree | Livegang |
|---|---|---|---|
| WOMS (Tasks 1–6) | JUSN/tickte-system, `feature/unterstuetzung` von `origin/test` | `/home/webklar/apps/ticket.webklar.com/dev-unterstuetzung` (existiert) | `git push origin feature/unterstuetzung:test` |
| Portal (Tasks 7–10) | Portal-Repo, `feature/unterstuetzung` von `origin/main` | `/home/webklar/apps/kundenbereich.webklar.com/dev-unterstuetzung` (in Task 7 anlegen) | `git push origin feature/unterstuetzung:main` |
| webklar.com (Task 11) | JUSN/Webklar, `fix/agb-haftung` (f40e889) | `/home/webklar/apps/webklar.com/dev-agb-haftung` (existiert, erst auf `origin/main` rebasen) | `git push origin fix/agb-haftung:main` |
Worktree-Regeln (alle drei Repos): `node_modules` ist ein Symlink auf `../build/node_modules`; nach Rebase/Checkout `rm -rf node_modules && ln -s ../build/node_modules node_modules`. In WOMS ist `node_modules` getrackt → nur `git add <dateien>`, nie `-A`. Nichts pushen ohne OK des Nutzers; der WOMS-Webhook deployt JEDEN gepushten Zweig.
## Global Constraints
- Zeit-Raster: `TIME_STEP = 15` Minuten; Auswahl 15 min … 12 h (`MAX_STEP_MINUTES = 720`, 48 Werte; 720 = bestehendes `MAX_MINUTES` der Push-Zeitmail). *Abweichung von der Spec (dort „bis 10 h“) zugunsten eines Maximums in beiden Systemen – Spec-Zeile wird in Task 1 angepasst.*
- Berechnete Dauer wird **aufgerundet** auf das nächste Vielfache von 15; Dauer 0/fehlend → 15 vorausgewählt.
- `supportMinutes(t) = t > 0 ? ceil(t / 2 / 15) * 15 : 0`; Testvektoren überall: 15→15, 30→15, 45→30, 60→30, 75→45, 70→45, 0→0, -5→0, '60'→30, null→0.
- `worksheetMinutes(ws) = base + supporters.length * supportMinutes(base)`, `base = Number(ws.totalTime) || 0`; Kommentar-Filter bleibt beim Aufrufer.
- `supporters`-Eintrag: `{ employeeId: string, name: string, task: string }`; `task` getrimmt 1–200 Zeichen; `employeeId` eindeutig; gespeichert als JSON-String, Attribut `supporters` String 4000, nicht Pflicht.
- Alte Arbeitsblätter werden nicht umgerechnet.
- Anzeige-Texte (exakt): Haken „Unterstützung“, Feld „Wobei?“, Knopf „+ weitere Person“, Knopf „Ich habe unterstützt“ / „Meine Unterstützung bearbeiten“, Listenzeile WOMS „↳ Unterstützung {Vorname} · {task} · {Zeit}“, Portal „↳ Unterstützung {Vorname}: {task}“ + Zeit, Portal-Kopf „Arbeitszeit gesamt: {Zeit}“, Stundenkonto-Zusatz „inkl. {Zeit} Unterstützung“.
- Vorname = erstes Wort von `name`.
- Zeitformat WOMS wie bisher `formatTime` („1h 30min“); Portal wie bisher `fmtMinutes`/`fmtHours`.
- Berechtigungen nur in der Oberfläche; Appwrite-Rechte bleiben unverändert.
- Claude darf keinen Appwrite-API-Key benutzen: Skripte mit Key führt der Nutzer aus.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Ersteller speichert das Zeiten-Formular, während ein Kollege sich gerade selbst als Unterstützung eingetragen hat → dessen Eintrag darf nicht still verloren gehen (Task 4: Konflikt-Prüfung beim Speichern).
2. Altes Arbeitsblatt mit 70 min wird zum Bearbeiten geöffnet → 75 ist vorausgewählt, und Unterstützung rechnet dann mit 75 (Task 3 + Task 1-Vektoren).
3. Git-Push: der zugeordnete Pusher sieht „Zeiten bearbeiten“ statt „Ich habe unterstützt“; ein Push ohne nachgetragene Zeit bietet keinem „Ich habe unterstützt“ an (Task 1 `canAddOwnSupport`, Task 5).
4. `supporters` kaputt (kein JSON, kein Array, Eintrag ohne `task`, doppelte Person) → WOMS-Liste, Portal-Projektansicht und Stundenkonto laufen weiter und ignorieren nur den kaputten Teil (Task 1, Task 7).
5. Mitarbeiterliste lädt nicht oder ein Mitarbeiter hat kein `userId` → Auswahl zeigt Hinweis statt leerer Zeilen, „Ich habe unterstützt“ funktioniert trotzdem mit `user.$id`/`user.name` (Task 4, Task 5).
---
## WOMS
### Task 1: Rechenregeln (Zeit-Raster + Unterstützung)
**Files:**
- Create: `src/lib/timeSteps.js`, `src/lib/support.js`
- Test: `tests/unit/support.test.mjs` (lädt Module über `tests/unit/load.mjs`)
- Modify: `docs/superpowers/specs/2026-09-30-unterstuetzung-design.md` („bis 10 h (40 Werte)“ → „bis 12 h (48 Werte)“)
**Interfaces:**
- Produces (`timeSteps.js`): `TIME_STEP = 15`, `MAX_STEP_MINUTES = 720`, `roundUpToStep(minutes: number|null): number` (≤0/NaN/null → 15, sonst `ceil(m/15)*15`), `isValidStep(minutes: any): boolean` (ganze Zahl, >0, %15===0, ≤720), `stepOptions(current?: number): {value:number,label:string}[]` (15…720; liegt `current` gültig über 720, wird er angehängt), `stepLabel(minutes): string` („15 min“, „1 h“, „1 h 15 min“).
- Produces (`support.js`): `supportMinutes(total): number`, `parseSupporters(raw: string|array|null): Supporter[]` (nie Exception; verwirft Einträge ohne `employeeId`/`task`, trimmt, kürzt `task` auf 200, erster Eintrag je `employeeId` gewinnt), `serializeSupporters(list): string`, `worksheetMinutes(ws): number`, `firstName(name): string`, `canAddOwnSupport(ws, user, employee): boolean` (`user.$id` vorhanden, `!ws.isComment`, `Number(ws.totalTime) > 0`, `!isWorksheetCreator(ws, user, employee)`), `upsertSupporter(list, entry): Supporter[]`, `removeSupporter(list, employeeId): Supporter[]`, `validateSupporters(list, creatorUserId): string|null` (Fehlertext: „Bitte bei jeder Person angeben, wobei sie unterstützt hat.“, „Jede Person nur einmal eintragen.“, „Du kannst dich nicht selbst als Unterstützung eintragen.“).
- Consumes: `isWorksheetCreator` aus `src/lib/ticketForm.js`.
- [ ] **Step 1: Failing tests** in `tests/unit/support.test.mjs`: Vektoren aus Global Constraints für `supportMinutes`; `roundUpToStep`: 0→15, 50→60, 60→60, 61→75, null→15; `isValidStep`: 15/720 true, 70/0/735/'x'/7.5 false; `stepOptions()` Länge 48, erstes `{15,'15 min'}`, `[3]` = `{60,'1 h'}`, `[4]` = `{75,'1 h 15 min'}`; `stepOptions(900)` Länge 49; `parseSupporters`: `'kaputt'`→[], `'{}'`→[], `'[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"}]'` → `[{employeeId:'a',name:'Andrej S',task:'Farbe'}]`; `worksheetMinutes({totalTime:60, supporters:<2 Einträge>})` = 120, `{totalTime:70, supporters:<1>}` = 115, ohne `supporters` = `totalTime`; `canAddOwnSupport`: fremdes normales Blatt mit Zeit → true, eigenes → false, Kommentar → false, Git ohne Zeit (`isComment:true,totalTime:0`) → false, Git mit Zeit gepusht von `knso` für `employee.shortcode 'KNSO'` → false, für anderen Mitarbeiter → true; `validateSupporters` für die drei Fehler und `null` für gültige Liste; `firstName('Andrej Stevanovski')` = 'Andrej'.
- [ ] **Step 2:** `npm test` → neue Tests FAIL (Modul fehlt).
- [ ] **Step 3:** `timeSteps.js` und `support.js` mit den Signaturen oben implementieren (reine Funktionen, keine Imports außer `ticketForm.js`).
- [ ] **Step 4:** `npm test` → alle grün.
- [ ] **Step 5:** Spec-Zeile anpassen; commit `git add src/lib/timeSteps.js src/lib/support.js tests/unit/support.test.mjs docs/superpowers/specs/2026-09-30-unterstuetzung-design.md && git commit -m "Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit"`.
### Task 2: Appwrite-Attribut `supporters` (Skript für den Nutzer)
**Files:**
- Create: `scripts/setup-supporters.mjs`
- Test: `tests/setup-supporters.test.mjs`
**Interfaces:**
- Produces: `export const ATTRIBUTE = { key: 'supporters', type: 'string', size: 4000, required: false }`; `export function planAttribute(existing: {key,type,size}[]): 'create'|'exists'|'conflict'` (`conflict` = Key vorhanden mit anderem Typ oder Größe < 4000). CLI wie `scripts/setup-legal.mjs`: `--dry-run`, `APPWRITE_API_KEY`, Endpoint/Projekt/DB-Defaults identisch, Collection `worksheets`; bei `create` POST `/databases/woms-database/collections/worksheets/attributes/string`, danach Status pollen bis `available` (max. 30 s); bei `conflict` Exit 1 mit Meldung. Hauptteil nur, wenn als Skript gestartet (`import.meta.url === pathToFileURL(process.argv[1]).href`).
- [ ] **Step 1: Failing test:** `planAttribute([])` = 'create'; `[{key:'supporters',type:'string',size:4000}]` = 'exists'; `[{key:'supporters',type:'string',size:255}]` = 'conflict'; `[{key:'supporters',type:'integer'}]` = 'conflict'.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Skript implementieren.
- [ ] **Step 4:** `npm test` grün; `node scripts/setup-supporters.mjs --dry-run` ohne Key → gibt geplanten Schritt aus, Exit 0, kein Netzwerkzugriff.
- [ ] **Step 5:** Commit „Unterstützung: Setup-Skript für das Attribut supporters“.
### Task 3: Zeiten nur in 15-Minuten-Schritten
**Files:**
- Create: `src/components/ui/DurationSelect.jsx` (+ Export in `src/components/ui/index.js`, falls dort gebündelt)
- Modify: `src/components/CreateWorksheetModal.jsx` (Feld „Arbeitszeit (Minuten)“ Z. ~238–254, Auto-Berechnung Z. ~60–67, Submit Z. ~105), `src/components/WorksheetTimesModal.jsx` (Z. 21–31, 117–134), `src/hooks/useWorksheets.js` (`createWorksheet` Z. ~146–153, `updateWorksheetTimes` Z. ~222–231)
- Test: `.superpowers/sdd/2026-09-30-unterstuetzung/ui.mjs` (Playwright, siehe Test-Rezept unten), `tests/unit/support.test.mjs` erweitern falls neue reine Hilfen
**Interfaces:**
- Consumes: `stepOptions`, `roundUpToStep`, `isValidStep`, `MAX_STEP_MINUTES` (Task 1), `minutesBetween` (ticketForm).
- Produces: `<DurationSelect id value onChange disabled />` – `<select>` über `stepOptions(value)`, `onChange(minutes:number)`; Label „Arbeitszeit“.
Verhalten: Beide Modals rechnen automatisch `roundUpToStep(minutesBetween(start,end))`, solange nicht manuell gewählt; Hinweis darunter „✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten“ bzw. „Von Hand gewählt“. `WorksheetTimesModal` startet mit `isValidStep(stored) ? stored : roundUpToStep(stored || berechnet)`. „Nur Kommentar“ deaktiviert die Auswahl und speichert 0. `createWorksheet` rundet eine fehlende/ungültige Zeit mit `roundUpToStep`; `createWorksheet` (nicht Kommentar) und `updateWorksheetTimes` lehnen `!isValidStep(totalTime)` ab mit „Die Arbeitszeit muss in 15-Minuten-Schritten angegeben werden.“
- [ ] **Step 1: Failing UI-Checks** in `ui.mjs`: (a) neues Arbeitsblatt 09:00–09:50 → Auswahl zeigt „1 h“, gespeichert `totalTime: 60`; (b) 23:30–00:15 → 45; (c) Auswahl hat 48 Optionen, keine freie Zahleneingabe (`input[type=number]` im Formular fehlt); (d) Zeiten bearbeiten bei `totalTime: 70` → „1 h 15 min“ vorausgewählt, Speichern schickt 75; (e) Git „Zeit nachtragen“ Beginn 14:00, Ende 14:37 → 45; (f) „Nur Kommentar“ → Auswahl disabled, gespeichert 0.
- [ ] **Step 2:** Harness bauen und laufen lassen → Checks FAIL.
- [ ] **Step 3:** `DurationSelect` bauen, in beiden Modals einsetzen, `useWorksheets` anpassen.
- [ ] **Step 4:** `npm test` grün; `ui.mjs` (a)–(f) grün.
- [ ] **Step 5:** Commit „Arbeitszeit nur in 15-Minuten-Schritten, berechnete Zeit wird aufgerundet“.
### Task 4: Unterstützung im Formular des Erstellers
**Files:**
- Create: `src/components/SupportersEditor.jsx`
- Modify: `src/components/WorksheetTimesModal.jsx`, `src/components/CreateWorksheetModal.jsx`, `src/hooks/useWorksheets.js`, `src/components/TicketCard.jsx` (Mitarbeiterliste an die Modals geben)
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `serializeSupporters`, `validateSupporters`, `supportMinutes`, `stepLabel` (Task 1); `useEmployees()` → `employees` mit `userId`, `displayName`.
- Produces: `<SupportersEditor value={Supporter[]} onChange={(list)=>void} employees={[]} excludeUserId={string} minutes={number} />` – Haken „Unterstützung“ (an, wenn `value.length`), Zeilen *Mitarbeiter* (`<select>`, ohne `excludeUserId`, ohne in anderen Zeilen gewählte, Mitarbeiter ohne `userId` weggelassen) + *Wobei?* (`maxLength 200`, required) + Entfernen; „+ weitere Person“; je Zeile Vorschau „= {stepLabel(supportMinutes(minutes))}“; Haken aus → `onChange([])`; keine Mitarbeiter → Hinweis „Mitarbeiterliste konnte nicht geladen werden.“
- Produces (`useWorksheets`): `updateWorksheetTimes(worksheet, times, currentUser, employee)` akzeptiert zusätzlich `times.supporters: Supporter[]` und `times.supportersBefore: string` (Rohwert beim Öffnen). Vor dem Schreiben: `databases.getDocument` frisch; ist `fresh.supporters ?? ''` ≠ `supportersBefore ?? ''`, Abbruch mit `{ success:false, error:'Inzwischen hat jemand die Unterstützung geändert. Bitte das Formular schließen und neu öffnen.' }`. Sonst patcht es `supporters: serializeSupporters(list)` mit. `createWorksheet(data, currentUser)` übernimmt `data.supporters` (Array) serialisiert.
- [ ] **Step 1: Failing UI-Checks:** (g) Ersteller öffnet „Zeiten bearbeiten“ (60 min), setzt Haken, wählt Andrej „Farbkonzept“ und Nico „Texte“ → PATCH enthält `supporters` mit beiden, Vorschau je „= 30 min“; (h) eigene Person fehlt in der Auswahl, schon gewählte Person fehlt in der zweiten Zeile; (i) leeres „Wobei?“ → Speichern blockiert mit Fehlertext aus Task 1; (j) Mock ändert `supporters` zwischen Öffnen und Speichern → Konfliktmeldung, kein PATCH; (k) neues Arbeitsblatt mit Unterstützung → `createDocument` enthält `supporters`; (l) Employees-Mock liefert 500 → Hinweistext sichtbar.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün (a)–(l).
- [ ] **Step 5:** Commit „Unterstützung: Ersteller trägt helfende Personen im Arbeitsblatt ein“.
### Task 5: „Ich habe unterstützt“
**Files:**
- Create: `src/components/OwnSupportModal.jsx`
- Modify: `src/components/WorksheetList.jsx` (Kopf-Knöpfe Z. ~164–200), `src/components/TicketCard.jsx`, `src/hooks/useWorksheets.js`
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `canAddOwnSupport`, `parseSupporters`, `upsertSupporter`, `removeSupporter`, `serializeSupporters` (Task 1).
- Produces (`useWorksheets`): `saveOwnSupport(worksheet, task: string|null, currentUser, employee): Promise<{success, error?}>` – prüft `canAddOwnSupport`, liest das Dokument frisch (`getDocument`), setzt/ersetzt den Eintrag `{ employeeId: user.$id, name: employee?.displayName || user.name, task }` bzw. entfernt ihn bei `task === null`, patcht nur `{ supporters }`, aktualisiert den State.
- Produces (`WorksheetList` Props): `canAddSupport(ws): boolean`, `onOwnSupport(ws): void`; Knopf-Text „Ich habe unterstützt“ bzw. „Meine Unterstützung bearbeiten“, wenn `parseSupporters(ws.supporters)` die eigene `user.$id` enthält (dafür neue Prop `currentUserId`).
- `OwnSupportModal`: Titel „Unterstützung eintragen“, Feld „Wobei?“ (required, 200), Anzeige „Dir werden {stepLabel(supportMinutes(ws.totalTime))} angerechnet.“, Knöpfe „Speichern“ und – wenn schon eingetragen – „Eintrag entfernen“.
- [ ] **Step 1: Failing UI-Checks:** (m) Nicht-Ersteller sieht bei fremdem 60-min-Blatt „Ich habe unterstützt“, Ersteller nicht; (n) Speichern mit „Design geprüft“ → vor dem PATCH ein GET des Dokuments; PATCH-Body enthält nur `supporters` mit dem bestehenden Eintrag (vom Mock) **und** dem neuen; (o) danach heißt der Knopf „Meine Unterstützung bearbeiten“, „Eintrag entfernen“ entfernt nur den eigenen; (p) Git-Push ohne Zeit und Kommentar: kein Knopf; Git-Push mit Zeit von `knso`: Kenso sieht „Zeiten bearbeiten“, Andrej sieht „Ich habe unterstützt“.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün.
- [ ] **Step 5:** Commit „Unterstützung: Mitarbeiter tragen sich selbst als Unterstützung ein“.
### Task 6: Anzeige in WOMS
**Files:**
- Modify: `src/components/WorksheetList.jsx` (eingeklappter Kopf Z. ~146–155 und aufgeklappter Körper Z. ~214–240), `src/components/WorksheetSummary.jsx`, `src/hooks/useWorksheets.js` (`getTotalTime` Z. ~255–259)
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `supportMinutes`, `worksheetMinutes`, `firstName` (Task 1).
Verhalten: Aufgeklappt unter Mitarbeiter/Zeit je Eintrag eine Zeile „↳ Unterstützung Andrej · Farbkonzept · 30min“ (Zeit mit dem vorhandenen `formatTime`). Eingeklappt bei vorhandener Unterstützung zusätzlich kleines Badge „+{n} Unterstützung“. `WorksheetSummary` und `getTotalTime` summieren `worksheetMinutes(ws)` über `!ws.isComment`.
- [ ] **Step 1: Failing UI-Checks:** (q) Blatt 60 min mit 2 Unterstützern → zwei Zeilen „↳ Unterstützung Andrej · Farbkonzept · 30min“ / „↳ Unterstützung Nico · Texte · 30min“, Kopf-Badge „+2 Unterstützung“; (r) Summe „Arbeitszeit gesamt 2h 0min“ bei diesem einen Blatt; (s) `supporters: 'kaputt'` → Blatt rendert normal ohne Zeilen, keine Konsolen-Exception.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün; Deploy-naher Build: `git archive HEAD | …` in `docker run --rm node:20-alpine` (`npm install && npm run build`) Exit 0.
- [ ] **Step 5:** Commit „Unterstützung: Anzeige in Arbeitsblatt-Liste und Gesamtzeit“.
**WOMS-Test-Rezept** (aus Memory, `ui.mjs`): esbuild-Harness mit JS-API (`require('<worktree>/node_modules/esbuild').build`), `define` für `import.meta.env.*`, Loader `.js=jsx`, `.ttf`/`.woff2`, Stub-Plugin für ProjectContextPanel/TicketProjects/InvoicePanel; Bau unter `systemd-run --scope -p MemoryMax=450M -p MemorySwapMax=0`, Playwright headless unter MemoryMax 700M, Bundle über `page.route` von `https://ticket.test`, Appwrite-REST (`/v1/account`, `/v1/databases/woms-database/collections/{worksheets,employees}/documents[/:id]`) gemockt, PATCH/POST-Bodies in `window.__calls` protokolliert. `set -o pipefail`, Exit-Codes prüfen.
---
## Kundenportal
### Task 7: Rechenregel + Stundenkonto
**Files:**
- Setup: `git -C /home/webklar/apps/kundenbereich.webklar.com/build fetch origin && git -C … worktree add -b feature/unterstuetzung ../dev-unterstuetzung origin/main`, dann node_modules-Symlink.
- Create: `server/services/worksheetMinutes.js`
- Modify: `server/services/hourLedger.js` (`listPlanUsage` Z. 54–70), `public/app.js` (`renderHoursView` Z. ~1218–1221)
- Test: `test/worksheetMinutes.test.js`, `test/hourLedger.test.js` (neu, falls nicht vorhanden; sonst erweitern)
**Interfaces:**
- Produces: `supportMinutes(total)`, `parseSupporters(raw)`, `worksheetMinutes(ws)`, `firstName(name)` – gleiche Semantik und Testvektoren wie WOMS Task 1; `parseSupporters` loggt bei kaputtem Wert einmal `console.warn('[unterstuetzung] supporters unlesbar', ws?.$id)` über optionalen zweiten Parameter `context`.
- Produces (`listPlanUsage` Eintrag): zusätzlich `supportMinutes: number` (Summe der Unterstützung); `rawMinutes = worksheetMinutes(ws)`, `minutes = roundUsage(rawMinutes)`; Filter `worksheetMinutes(ws) > 0`.
- [ ] **Step 1: Failing tests:** Vektoren; `parseSupporters` wie WOMS; `listPlanUsage` mit gemocktem `listDocuments` (Muster aus `test/support/appwriteMock`) – Blatt 60 min + 1 Unterstützer → `minutes 90, supportMinutes 30`; Blatt mit `supporters:'kaputt'` → `minutes 60`, kein Throw; `computeLedger` mit 60 min Guthaben und diesem 90-min-Eintrag → `overageMinutes 30`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Implementieren; in `renderHoursView` hinter `−{fmtHours(u.minutes)}` bei `u.supportMinutes` ein `<span class="muted">inkl. {fmtHours(u.supportMinutes)} Unterstützung</span>`.
- [ ] **Step 4:** `npm test` grün (bekannt flaky/unabhängig: `repoMove.test.js`).
- [ ] **Step 5:** Commit „Unterstützung: Rechenregel und Abzug im Abo-Stundenkonto“.
### Task 8: Projektansicht – Unterstützungs-Zeilen und Gesamtzeit
**Files:**
- Modify: `server/services/customerProjectView.js` (`customerWorkEntry` Z. ~197–216, `customerWorkEntries` Z. ~219–233), `server/routes/projects.js` (`GET /:id/work` Z. ~78–95), `public/app.js` (`renderWorkEntry` Z. ~319–336, `renderOrder` Z. ~252–273, Projektdetails Z. ~386–396), `public/kundenbereich.css`, `server/services/customerActivity.js` (Z. ~36–47), `public/admin.js` (Z. ~167)
- Test: `test/customerProjectView.test.js` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `supportMinutes`, `worksheetMinutes`, `firstName` (Task 7).
- Produces: Work-Eintrag bekommt `supports: { name: string /*Vorname*/, task: string, minutes: number }[]` (leer = `[]`); zusammengefasste Git-Tage hängen `supports` aneinander; `minutes` des Eintrags bleibt die eigene Arbeitszeit (ohne Unterstützung). Neue Export-Funktion `workTotalMinutes(entries): number` = Σ (`minutes` + Σ `supports.minutes`). `GET /:id/work` antwortet zusätzlich `totalMinutes`. `customerActivity` liefert je Blatt zusätzlich `supportMinutes` (Summe); `admin.js` zeigt dann „, +{n} min Unterstützung“.
- Anzeige: `renderWorkEntry` hängt je Unterstützung `<li class="pd-support">↳ Unterstützung {name}: {task} <span class="pd-time">{fmtMinutes}</span></li>` in einer `<ul class="pd-supports">` unter den Eintrag. Kopf: bei `order` in `pd-order-head` ein `<span class="pd-total">Arbeitszeit gesamt: {fmtMinutes(totalMinutes)}</span>`; ohne `order` als erste Zeile des Abschnitts „Arbeiten am Projekt“; bei 0 nichts.
- [ ] **Step 1: Failing tests:** normales Blatt 60 min + Andrej „Farbkonzept“ → `supports: [{name:'Andrej', task:'Farbkonzept', minutes:30}]`, `minutes 60`; zwei Git-Pushs am selben Tag je mit einem Unterstützer → ein Eintrag mit zwei `supports`; `workTotalMinutes` über diese Einträge; Kommentar mit `supporters` → kein Eintrag; `supporters:'kaputt'` → `supports: []`.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren (Server + `app.js` + CSS: `.pd-supports` eingerückt, gleiche Schriftgröße wie `.pd-entry-meta`).
- [ ] **Step 4:** `npm test` grün; UI-Blick mit dem statischen Fixture-Server aus [[kundenportal-testing]] (Projektdetails mit `work`-Fixture inkl. `totalMinutes` und `supports`, Desktop + 390 px Breite, Screenshot in den SDD-Ordner).
- [ ] **Step 5:** Commit „Unterstützung: eigene Zeilen und Gesamtzeit in den Projektdetails“.
### Task 9: Push-Zeitmail nur in 15-Minuten-Schritten
**Files:**
- Modify: `server/services/pushTimeEntry.js` (`parseMinutes` Z. ~40–47), `server/routes/pushTime.js` (Formular Z. ~153–156)
- Test: `test/pushTimeEntry.test.js` bzw. `test/pushTimeRoute.test.js` erweitern
**Interfaces:** `parseMinutes(raw)` akzeptiert nur ganze Vielfache von 15 in 15–720, sonst `null`. Das Formular wird ein `<select name="minutes">` mit 15 … 720 in 15er-Schritten (Label wie `formatMinutes`), vorausgewählt `minutes` aus der Query bzw. 60. `QUICK_MINUTES` bleibt.
- [ ] **Step 1: Failing tests:** `parseMinutes('45')`=45, `'720'`=720, `'70'`=null, `'0'`=null, `'735'`=null; Route-Test: GET `/zeit/<token>` enthält `<select name="minutes"` mit 48 `<option`; POST `minutes=70` → Fehlerseite, kein Schreiben.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren; Fehlertext „Bitte eine Zeit in 15-Minuten-Schritten wählen.“
- [ ] **Step 4:** `npm test` grün.
- [ ] **Step 5:** Commit „Push-Zeitmail: Zeit nur in 15-Minuten-Schritten“.
### Task 10: Abo-Bedingungen Fassung 5
**Gate:** Vor Step 3 den endgültigen § 4-Text (Spec, Abschnitt Verträge) dem Nutzer im Chat vorlegen und auf sein OK warten.
**Files:**
- Create: `public/vertragsunterlagen/v4/abo-bedingungen.html` + `public/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf` (bytegleiche Kopien der heutigen Fassung 4)
- Modify: `public/abo-bedingungen.html` (§ 4 neuer Satz nach der Rundungsregel; Kopf „Version 5 · Stand {Datum}“ an beiden Stellen), `server/config.js` (`termsVersion: '{yyyy-mm-dd}-v5'` Z. ~158), `server/services/products.js` (`isTermsV4`/`termsDocFor` Z. ~28–55), `scripts/build-legal-pdfs.sh`, `public/WEBklar-Abo-Vertragsunterlagen.pdf` (neu erzeugt), `test/legalTexts.test.js`, `test/products.test.js`, alle Fundstellen von `isTermsV4`/`-v4` (`server/routes/abo.js`, `test/mailTemplates.test.js`, `server/mail-templates/K01 Vertragsbestaetigung.html`)
- `{Datum}` = Tag der Umsetzung; verschiebt sich der Livegang, im Livegang-Schritt anpassen.
**Interfaces:**
- Produces (`products.js`): `isCurrentTerms(v): boolean` (endet auf `-v5`), `isTermsV4OrNewer(v): boolean` (endet auf `-v4` oder `-v5`), `termsDocFor(v)`: `-v5` → `{ url:'/abo-bedingungen.html', pdfFile:'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph:'§ 11' }`; `-v4` → `{ url:'/vertragsunterlagen/v4/abo-bedingungen.html', pdfFile:'vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph:'§ 11' }`; sonst v3 wie bisher. `contractAttachmentFor` liefert als `filename` den Basisnamen. Alle bisherigen Aufrufer von `isTermsV4`, die „aktuelle Fassung“ meinen, nutzen `isCurrentTerms`; wo „Fassung 4 oder neuer“ gemeint ist (Hosting/Zusatzspeicher/Wechsel), `isTermsV4OrNewer`. `isTermsV4` entfällt.
- [ ] **Step 1: Failing tests** (`legalTexts.test.js`): Fassung 4 bytegleich archiviert (sha256 HTML = `git show origin/main:public/abo-bedingungen.html`, fest eingetragen wie V3_SHA256); Fassung 5 enthält „Version 5“, nicht „Version 4“, § 4 enthält „die Hälfte der Arbeitszeit dieses Arbeitsschritts“, „aufgerundet auf volle 15 Minuten“, „jede Unterstützung mit Person, Tätigkeit und angerechneter Zeit“; Widerrufsbelehrung + Formular wortgleich mit Fassung 4. `products.test.js`: `termsDocFor` für `-v5`, `-v4`, `-v3`, `undefined`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Nach OK des Nutzers: Archiv anlegen, Text einfügen, Version hochziehen, Code anpassen, PDFs mit `scripts/build-legal-pdfs.sh` neu erzeugen. Weitere Fundstellen der Rundungs-/Stundenregel suchen (`grep -rn "15 Minuten" public server/mail-templates`), bei Abweichung angleichen.
- [ ] **Step 4:** `npm test` grün; PDF öffnen und § 4 prüfen (Seitenumbruch, Kopfzeile „Version 5“).
- [ ] **Step 5:** Commit „Abo-Bedingungen Fassung 5: Unterstützung in § 4, Fassung 4 archiviert“.
## webklar.com
### Task 11: AGB § 9 Absatz 2
**Gate:** Endgültigen Text dem Nutzer vorlegen (zusammen mit Task 10 möglich), OK abwarten.
**Files:**
- Vorher: `git -C /home/webklar/apps/webklar.com/dev-agb-haftung fetch origin && git … rebase origin/main` (f40e889 liegt noch auf 1637a4a; main ist 3c9a879), node_modules-Symlink prüfen.
- Modify: `src/pages/AGB.tsx` (§ 9, Z. ~188–198: neuer zweiter Listenpunkt, bisherige 2 und 3 werden 3 und 4)
- Test: `src/test/agb-verguetung.test.tsx`
Text (vollständig ausgeschrieben, kein Verweis): „Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.“ (AGB sprechen von „WEBklar“ in der dritten Person, daher angepasst.)
- [ ] **Step 1: Failing test:** § 9 hat 4 Absätze; Absatz 1 unverändert „Die Vergütung ergibt sich aus dem Angebot.“; Absatz 2 = Text oben; Absatz 4 „Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.“
- [ ] **Step 2:** `npx vitest run src/test/agb-verguetung.test.tsx` → FAIL.
- [ ] **Step 3:** Absatz einfügen; prüfen, ob irgendwo auf „§ 9 Abs. 2/3“ der AGB verwiesen wird (`grep -rn "9 Abs" src` in webklar.com und `public/` des Portals) und anpassen.
- [ ] **Step 4:** `npx vitest run` grün (inkl. `agb-haftung.test.tsx`); `npm run build` Exit 0.
- [ ] **Step 5:** Commit „AGB § 9: Zeiterfassung in 15-Minuten-Schritten und Unterstützung“.
## Livegang (nur mit OK des Nutzers, in dieser Reihenfolge)
- [ ] 1. Nutzer: `cd /home/webklar/apps/ticket.webklar.com/dev-unterstuetzung && APPWRITE_API_KEY=… node scripts/setup-supporters.mjs` → „available“.
- [ ] 2. Portal: `git -C …/kundenbereich.webklar.com/dev-unterstuetzung push origin feature/unterstuetzung:main`; prüfen: Deploy-Marker/Commit im Container, `/api/projects/:id/work` liefert `totalMinutes`, Stundenkonto lädt, `/abo-bedingungen.html` zeigt „Version 5“, `/vertragsunterlagen/v4/abo-bedingungen.html` erreichbar.
- [ ] 3. WOMS: `git -C …/ticket.webklar.com/dev-unterstuetzung push origin feature/unterstuetzung:test`; prüfen: Live-Bundle enthält „Ich habe unterstützt“, Arbeitszeit-Auswahl vorhanden.
- [ ] 4. webklar.com: `git -C …/webklar.com/dev-agb-haftung push origin fix/agb-haftung:main`; `/agb` zeigt § 9 Abs. 2 und § 11 neu.
- [ ] 5. Nutzer lädt in WOMS „Rechtliches“ die neuen Versionen hoch (Abo-Bedingungen v5 PDF, AGB neue Version) mit Änderungsnotiz.
- [ ] 6. Lesender Check mit echten Daten im Chrome des Nutzers: ein Arbeitsblatt mit Unterstützung anlegen (Test-Ticket), Portal-Projektansicht zeigt die Zeile und Gesamtzeit.
- [ ] 7. Memory aktualisieren (woms-ticketsystem, webklar-kundenportal, webklar-website).
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
Appwrite-Vorlage "MFA-Challenge" (Auth → Templates → Sprache Deutsch)
Betreff: Dein Anmeldecode für WEBklar
Platzhalter (Appwrite): {{otp}}, {{user}}
Code gilt 1 Stunde (Appwrite fest).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Dein Anmeldecode für WEBklar</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Dein Code: {{otp}}&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Anmeldung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Anmeldecode</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{user}}, hier ist dein Code</td></tr>
<tr><td class="px ink" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;color:#111111;">Gib ihn im Ticketsystem oder im Projekt-Admin ein, um die Anmeldung abzuschließen.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td align="center" class="ink" style="padding:22px;font-family:'Courier New',Courier,monospace;font-size:36px;line-height:40px;font-weight:bold;letter-spacing:10px;color:#111111;">{{otp}}</td></tr></table></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Der Code gilt 1 Stunde. Nicht du? Dann ändere bitte dein Passwort.</td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Nachricht von WEBklar · Bitte nicht antworten.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,169 @@
# Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem ⇄ Projekt-Admin
Stand: 2026-09-30 · Status: Design vom Nutzer freigegeben (Teil 1 + Teil 2), Spec wartet auf Review
## Ziel
1. Jeder Mitarbeiter-Login (Ticketsystem ticket.webklar.com **und** Projekt-Admin project.webklar.com)
verlangt nach dem Passwort einen 6-stelligen Code, der per E-Mail kommt. Pflicht für alle
Mitarbeiter. Kunden bleiben unverändert.
2. Aus der Seitenleiste des Ticketsystems springt man ohne zweiten Login ins Projekt-Admin; aus dem
Projekt-Admin führt ein Button zurück ins Ticketsystem.
Entscheidungen des Nutzers: E-Mail-Code statt TOTP/KeePass; 2FA über Appwrite selbst (Variante A),
nicht als reine Oberflächen-Sperre; Pflicht für alle Mitarbeiter.
## Ausgangslage (geprüft)
- Beide Systeme nutzen dieselben Appwrite-Konten (Appwrite **1.8.1**, Projekt `6a1058610003c5a13a05`).
- Appwrite verschickt bereits Mails: `_APP_SMTP_HOST=mail.your-server.de:587`, Absender
`WEBklar <noreply@webklar.com>`.
- Appwrite-MFA (1.8.1): Ist `user.mfa = true` und die E-Mail bestätigt (`emailVerification = true`),
braucht jede Sitzung 2 Faktoren. Eine Passwort-Sitzung hat nur den Faktor `password`; jeder
Nicht-MFA-Aufruf liefert dann `401 user_more_factors_required`. Das gilt auch für **bereits
bestehende** Sitzungen und für JWTs aus solchen Sitzungen.
- `POST /v1/account/mfa/challenges` `{factor:'email'}` → `{ $id, expire }`, schickt die Mail
(Vorlage `mfaChallenge`). Code gilt **1 Stunde**. Limit 10 Anfragen pro Nutzer und Zeitfenster.
- `PUT /v1/account/mfa/challenges` `{challengeId, otp}` → Sitzung bekommt Faktor `email`.
Falscher/abgelaufener Code: `401 user_invalid_token`.
- Server-API mit Key: `PATCH /v1/users/:id/mfa {mfa}`, `PATCH /v1/users/:id/verification {emailVerification}`.
- Ticketsystem: Appwrite-Web-SDK **13.0.2** (kennt kein MFA). AuthContext meldet per
`createEmailPasswordSession` an und prüft mit `account.get()`.
- Projekt-Admin: `POST /api/auth/login` ruft serverseitig `/account/sessions/email` ohne API-Key auf,
nimmt nur die `userId` und setzt die Portal-Sitzung (`role: 'admin'`) für Mitarbeiter
(`resolveStaffAccount`). **Mit MFA wäre das eine Umgehung**, weil die halbe Sitzung reicht.
- `requireAppwriteStaff` (JWT-Prüfung für `/api/admin/*` aus dem Ticketsystem) verlangt das
Label `admin`. Mitarbeiter ohne Admin-Label kommen per Portal-Login trotzdem ins Projekt-Admin
(`staff.isAdmin = false`).
- Einmal-Link-Mechanismus existiert: `services/staffPreviewTokens.js` (Map im Speicher, 60 s, einmalig),
genutzt von `/api/admin/preview-open` → `/api/auth/staff-preview`.
- Projekt-Admin (`public/admin.html`) hat keine Seitenleiste, sondern Kopfzeile + Tabs.
## Teil 1: Anmeldung mit E-Mail-Code
### Ablauf für Mitarbeiter
1. E-Mail + Passwort.
2. Anzeige „Wir haben dir einen Code an k…@webklar.com geschickt“ + 6-stelliges Codefeld
(`inputmode="numeric"`, `autocomplete="one-time-code"`), Link „Code erneut senden“, Link „Abbrechen“
(meldet die halbe Sitzung ab, zurück zur Passwort-Eingabe).
3. Richtiger Code → angemeldet. Falscher/abgelaufener Code → „Code falsch oder abgelaufen“, Feld bleibt.
### Ticketsystem (Repo JUSN/tickte-system)
- **Kein SDK-Upgrade.** Neues Modul `src/lib/mfa.js` mit zwei Funktionen über `client.call(...)` des
vorhandenen SDK-Clients (nutzt dieselbe Sitzung/Cookie-Fallback wie das SDK):
`createEmailChallenge()` → `challengeId`; `completeChallenge(challengeId, otp)`.
Beide senden `X-Appwrite-Locale: de`, damit die deutsche Vorlage greift.
- `AuthContext`:
- neuer Zustand `mfaPending` (`{ challengeId, email }` oder `null`).
- `login()`: nach der Passwort-Sitzung `account.get()`; bei `user_more_factors_required` →
Challenge anlegen, `mfaPending` setzen, `{ success: false, mfaRequired: true }` zurück.
- `checkUser()` beim Laden: gleicher Fall (bestehende halbe Sitzung, z. B. direkt nach dem
Einschalten) → **keine** neue Mail automatisch, sondern Code-Schritt mit Knopf „Code senden“.
Grund: jedes Neuladen würde sonst eine Mail auslösen und das Limit (10) aufbrauchen.
- `verifyCode(otp)`, `resendCode()`, `cancelMfa()` (= `deleteSession('current')`).
- Nach erfolgreichem Code wie bisher `account.get()` + `ensureEmployeeExists`.
- `LoginPage.jsx`: zweiter Schritt „Code eingeben“; Routing: solange `mfaPending`, führt jede
geschützte Route auf `/login`.
- Fehlertexte: `user_invalid_token` → „Code falsch oder abgelaufen“; `429`/`general_rate_limit_exceeded`
→ „Zu viele Versuche, bitte einige Minuten warten“; `user_email_not_verified` →
„2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben“.
### Projekt-Admin (Repo Kundenportal, project.webklar.com)
- `loginWithAppwrite` liest zusätzlich die Sitzung aus der Antwort (`set-cookie`
`a_session_<projectId>` via `headers.getSetCookie()`), damit der Server im Namen dieser Sitzung
weiterarbeiten kann.
- `POST /api/auth/login` für **Mitarbeiter**: Ist `account.mfa === true` (aus `getUserById`), wird
**keine** Portal-Sitzung gesetzt. Stattdessen:
- Challenge über die Sitzung anlegen, Eintrag in einer Map im Speicher
`{ userId, email, appwriteSessionCookie, challengeId, expiresAt: +10 min, attempts: 0 }`
unter zufälligem Schlüssel; Schlüssel als signiertes, httpOnly-Cookie `webklar_portal_mfa`.
- Antwort `{ mfaRequired: true, email: <maskiert> }`.
- `POST /api/auth/login/mfa` `{ otp }`: prüft den Code bei Appwrite (`PUT /account/mfa/challenges`
mit der gespeicherten Sitzung). Erfolg → exakt der bisherige Mitarbeiter-Zweig (Portal-Sitzung +
Preview-Cookie), Map-Eintrag + Cookie löschen. Max. 5 Fehlversuche pro Eintrag, dann Eintrag weg
→ neu anmelden.
- `POST /api/auth/login/mfa/resend`: neue Challenge für denselben Eintrag.
- Serverneustart verwirft offene Einträge → Nutzer meldet sich neu an (akzeptiert).
- Kunden-Zweig bleibt unverändert (Kunden haben `mfa = false`).
- `public/login.html` + `app.js`: zweiter Schritt mit Codefeld, gleiche Texte wie im Ticketsystem.
### Einmalige Einrichtung (Skript, nach dem Livegang beider Teile)
- Skript `scripts/mitarbeiter-2fa.mjs` im Kundenportal-Repo (läuft im Container mit dem Server-Key):
- `--status`: listet alle Mitarbeiter (Collection `employees` → `userId`) mit `emailVerification`
und `mfa`.
- `--enable [userId|alle]`: setzt `emailVerification = true`, dann `mfa = true`.
- `--disable <userId>`: Notfall-Schalter, wenn bei einer Person keine Mail ankommt.
- Vor dem Einschalten wird die E-Mail-Adresse jedes Kontos angezeigt; nur Adressen, die
tatsächlich Post empfangen, werden bestätigt.
- Wirkung: Alle bestehenden Mitarbeiter-Sitzungen fragen beim nächsten Aufruf nach dem Code.
- Keine Wiederherstellungscodes (Faktor ist das Postfach; Notfall = `--disable`).
### Code-Mail
- Appwrite-Vorlage `mfaChallenge`, Sprache `de`, als eigene Vorlage im Projekt hinterlegt
(Betreff „Dein Anmeldecode für WEBklar“, Inhalt im Stil der WEBklar-Mailvorlagen: Logo, Code groß,
„gilt 1 Stunde“, „Nicht du? Dann ändere bitte dein Passwort.“).
- Wird über die Appwrite-Console gesetzt (Auth → Templates) – der Nutzer ist dort eingeloggt; ich
bereite HTML und Betreff vor.
## Teil 2: Wechsel zwischen Ticketsystem und Projekt-Admin
### Hinweg (Ticketsystem → Projekt-Admin, ohne zweiten Login)
- `Navbar.jsx`: neuer Eintrag **„Projekt-Admin“** mit Symbol `IconExternalLink` nach „Finanzen“ /
„Admin“, für alle Mitarbeiter sichtbar. Klick → `openProjectAdmin()` → `window.location.assign(openUrl)`
(selber Tab). Während des Ladens deaktiviert; bei Fehler Fallback auf
`https://project.webklar.com/login.html`.
- Kundenportal: `POST /api/admin/switch-link` (Bearer-JWT aus dem Ticketsystem):
- eigene Prüfung statt `requireAppwriteStaff`: JWT gültig (`/account`) **und** `resolveStaffAccount`
findet einen Mitarbeiter – so kommen auch Mitarbeiter ohne Admin-Label rüber, wie beim normalen
Login.
- Antwort `{ openUrl: 'https://project.webklar.com/api/auth/staff-switch?token=…' }`, Token über
`createStaffPreviewToken` (60 s, einmalig) mit `{ userId, email, name, isAdmin, purpose: 'switch' }`.
- `GET /api/auth/staff-switch?token=…`: `consumeStaffPreviewToken`; nur `purpose === 'switch'`
akzeptiert (und `staff-preview` akzeptiert umgekehrt keine Switch-Tokens). Setzt Portal-Sitzung +
Preview-Cookie wie der Mitarbeiter-Login, Redirect `/admin.html`. Ungültig/abgelaufen → Redirect
`/login.html?error=Link abgelaufen, bitte anmelden`.
- 2FA bleibt gewahrt: Das JWT stammt aus einer Sitzung, die nach Teil 1 nur mit Code voll gültig ist.
### Rückweg (Projekt-Admin → Ticketsystem)
- `admin.html` Kopfzeile: Button **„← Ticketsystem“** (Link `https://ticket.webklar.com/tickets`)
vor „Abmelden“. Nur in der Admin-Ansicht; im Modus „Als Kunde ansehen“ und bei Kunden nicht.
- Kein automatischer Login in diese Richtung: Ist die Ticketsystem-Sitzung noch aktiv, ist man direkt
drin, sonst Login mit Code.
## Tests
Mit nachgebautem Appwrite (Mock nach 1.8.1-Verhalten, keine echten Daten):
- Ticketsystem: Login ohne MFA unverändert; Login mit MFA → Code-Schritt; richtiger Code;
falscher Code; abgelaufener Code; erneut senden; Abbrechen; halbe Sitzung beim Neuladen (keine
automatische Mail); 429-Text; Seitenleisten-Eintrag öffnet `openUrl`, Fehler → Fallback.
- Projekt-Admin: Kunden-Login unverändert; Mitarbeiter ohne MFA unverändert; Mitarbeiter mit MFA
bekommt **keine** Portal-Sitzung vor dem Code; richtiger/falscher Code; 5 Fehlversuche; resend;
switch-link ohne/mit ungültigem JWT (401), mit halber Sitzung (401), Kunde (403), Mitarbeiter ohne
Admin-Label (200); staff-switch einmalig, abgelaufen, falscher `purpose`; Rückweg-Button nur für
Mitarbeiter und nicht im „Als Kunde ansehen“-Modus.
- Bestehende Testsuites beider Repos grün.
## Livegang (Reihenfolge)
1. Kundenportal live (Push nach `main` durch den Nutzer bzw. auf sein OK).
2. Ticketsystem live (Push nach `test` durch den Nutzer).
3. Code-Mail-Vorlage in der Appwrite-Console hinterlegen.
4. `mitarbeiter-2fa.mjs --status`, dann zuerst nur für Kenso `--enable`, einmal in beiden Systemen
durchspielen, dann `--enable alle`.
Ohne Schritt 4 ändert sich für niemanden etwas (MFA aus → Login wie bisher); Teil 2 funktioniert
schon nach Schritt 1+2.
## Nicht enthalten
- TOTP/KeePass, SMS, Wiederherstellungscodes, 2FA für Kunden.
- Automatischer Login vom Projekt-Admin zurück ins Ticketsystem.
- SDK-Upgrade des Ticketsystems.
@@ -0,0 +1,72 @@
# WOMS „Rechtliches“ – Konzept
Stand 2026-09-30, freigegeben im Gespräch (Umfang A, Rolle „Archiv + Überblick“, Anbieter mit Fristen).
## Ziel
Ein interner Menüpunkt **Rechtliches** (`/rechtliches`) in WOMS, der alle Rechtstexte von WEBklar mit
lückenloser Versionsgeschichte und alle Verträge mit Anbietern samt Fristen an einer Stelle zeigt.
Nur Mitarbeiter, keine Kunden. Die Texte werden weiter auf webklar.com und im Kundenportal gepflegt;
WOMS speichert feste Kopien jeder Version (Archiv, keine Quelle).
Nicht im Umfang: Firmenunterlagen, kundenbezogene Dokumente, Mail-Erinnerungen, automatische
Übernahme neuer Versionen aus den Deploys.
## Oberfläche
Seite mit zwei Reitern.
**Unsere Rechtstexte** – je Text eine Karte: AGB, Abo-Bedingungen, AVV, Datenschutz Website,
Datenschutz Portal, Impressum, Widerrufsbelehrung/-formular. Karte zeigt aktuelle Version, „gilt seit“,
Link zur Live-Seite. Klick öffnet die Versionsgeschichte (neu → alt): Nummer, gültig von/bis,
Änderungsnotiz, Datei ansehen/herunterladen. Aktuell = grün, Entwurf = graues Abzeichen.
„Neue Version“: Datei, Nummer, gültig ab, Notiz, Status. Wird eine Version gültig, bekommt die bisher
gültige automatisch `validTo` = neues `validFrom` minus 1 Tag und Status „abgelöst“.
**Verträge mit Anbietern** – Liste mit Anbieter, Zweck, Kosten, Laufzeit, Kündigungsfrist, nächster
Kündigungstermin; sortiert nach nächstem Termin. Rot ≤ 30 Tage, orange ≤ 90 Tage, grau = gekündigt/beendet,
„Fristen fehlen“ wenn Angaben unvollständig. Detailansicht mit Dateien und Notiz; Anlegen, Bearbeiten,
„Als gekündigt markieren“.
## Daten (Appwrite `woms-database`)
- `legalDocuments`: `key` (z. B. `agb`), `title`, `liveUrl`, `sort`.
- `legalVersions`: `documentKey`, `version` (Text, z. B. „v6“), `validFrom`, `validTo`,
`status` (`draft` | `current` | `superseded`), `changeNote`, `fileIds[]`.
- `providerContracts`: `provider`, `purpose`, `cost`, `costInterval` (`month` | `year`), `startDate`,
`termMonths`, `renewalMonths` (0 = keine Verlängerung), `noticeValue` + `noticeUnit` (`days` | `months`),
`cancelledAt`, `endsAt`, `note`, `fileIds[]`.
Bucket `legal-files`: PDF, HTML, Bilder, DOC/DOCX, max. 20 MB.
Rechte: `users` dürfen lesen, anlegen, bearbeiten. Kein Löschen bei Versionen und Dateien (Archiv bleibt
vollständig); Anbieter-Verträge dürfen gelöscht werden.
Anlage per Setup-Skript (`scripts/setup-legal.mjs`, idempotent wie `setup-woms-appwrite.mjs`),
braucht einmal einen Appwrite-API-Key.
## Code
- `src/lib/legal.js`: `nextCancellationDate(contract, today)`, `deadlineTone(date, today)`,
`supersedePrevious(versions, newVersion)`; reine Funktionen.
- `src/hooks/useLegal.js`: Laden/Speichern der drei Sammlungen, Datei-Upload/-Link über Appwrite Storage.
- `src/pages/LegalPage.jsx` mit den Reitern; Route und Menüpunkt in `App.jsx`/`Navbar.jsx`.
`nextCancellationDate`: Ende der laufenden Periode = Start + Laufzeit, danach je + Verlängerung, bis das
Periodenende nach heute liegt; Termin = Periodenende minus Kündigungsfrist (Monatsende korrekt geklemmt,
z. B. 31.01. + 1 Monat = 28./29.02.). Liegt der Termin schon vorbei, zählt die nächste Periode.
Ohne Verlängerung: Vertragsende, danach „beendet“. Gekündigt: kein Termin, grau. Fehlende Angaben: `null`
→ „Fristen fehlen“.
## Erstbefüllung
Skript `scripts/seed-legal.mjs`: Versionen aus der Git-Geschichte von webklar.com (`JUSN/Webklar`) und
dem Kundenportal rekonstruieren (je live gegangene Fassung eine Version, Datum = Commit auf main,
gespeichert als HTML/PDF). Die Versionsliste bekommt der Nutzer **vor dem Eintragen** zur Prüfung,
damit die Nummerierung (AGB aktuell „v6“) zu seiner passt. Nicht-live-Stände (z. B. Abo-Bedingungen v4)
als `draft`. Anbieter Hetzner (Server, Mail), Porkbun, Stripe als Einträge ohne geratene Fristen/Preise.
## Test und Auslieferung
Unit-Tests für `lib/legal.js` (Monatsende, Schaltjahr, keine Verlängerung, gekündigt, fehlende Angaben,
Ablösung). Playwright-Test gegen Vite-Build mit gemocktem Appwrite (Reiter, Karte, Versionsgeschichte,
neue Version löst alte ab, Farben der Fristen). Worktree `dev-rechtliches`, Branch `feature/rechtliches`;
live erst nach OK des Nutzers per `push … :test`.
@@ -0,0 +1,198 @@
# Serverpuls – Server-Monitoring in WOMS
Stand: 2026-09-30 · Status: Design vom Nutzer im Chat freigegeben, Spec zur Prüfung
## Ziel
Ein neuer Navbar-Eintrag **Serverpuls** in WOMS (ticket.webklar.com) zeigt Admins die
Auslastung des Servers und das Wachstum der einzelnen Projekte über Tage, Wochen und
Monate – als Verlaufskurven „wie bei Aktien“. Feste Warnregeln (keine KI) melden
Probleme per E-Mail in drei Stufen.
Ausdrücklich **nicht KI-gesteuert**: Messung, Auswertung und Mails laufen
deterministisch in einem kleinen Dienst ohne Claude.
## Rahmenbedingungen
- Server: 8 Kerne, 15 GB RAM, 4 GB Swap (am 2026-09-30 voll), Platte 150 GB (76 % belegt),
~61 Container. Der Dienst selbst muss sehr sparsam sein (Limit 128 MB RAM, 0,5 CPU).
- WOMS ist eine statische React/Vite-SPA auf Appwrite; Server-Logik liegt in kleinen
Node-Diensten hinter dem WOMS-nginx (Vorbild: `ticket-integrations`,
`/api/integrations/`, Auth über Appwrite-JWT).
- Admin in WOMS = Appwrite-Konto mit Label `admin` (`AuthContext.jsx`).
- Der Deploy-Webhook von WOMS deployt jeden gepushten Branch; live geht nur per Push
des Nutzers nach `test`.
## Entscheidungen
| Frage | Entscheidung |
|---|---|
| Name | Serverpuls |
| Rolle der KI | keine; feste Warnregeln |
| „Projekt“ | jede App / jeder Container (Previews als eigene Apps) mit CPU, RAM, Speicher |
| Warnkanal | E-Mail in drei Stufen Low / Medium / Critical |
| Mail-Takt | Critical sofort, Medium Sammelmail täglich 07:00, Low Wochenübersicht Mo 07:00 |
| Mail-Adresse | `server@webklar.com` ist Absender **und** einziger Empfänger |
| Umsetzung | eigener schlanker Dienst (kein Beszel, kein Netdata) |
| Sichtbarkeit | nur Admins (Navbar und API) |
## Architektur
```
Host /proc (ro) ──┐
docker-socket-proxy (nur GET containers/stats) ──┼──> serverpuls (Node 22, SQLite) ──> SMTP server@
/home/webklar/apps + Docker-Volumes (ro) ──┘ │
└── HTTP :8091 <── WOMS-nginx /api/serverpuls/ <── ServerpulsPage (uPlot)
```
### Einheiten
1. **Collector** (`collect/`): liest Messwerte, gibt reine Datenobjekte zurück.
- `host.js` – `/proc/stat` (CPU % aus Differenz), `/proc/meminfo` (RAM verfügbar,
Swap), `/proc/loadavg`, Zombies = Prozesse mit State `Z` in `/proc/*/stat`.
Plattenbelegung `/` per `statfs` auf `/host/apps` (gleiches Dateisystem `/dev/sda1`).
- `docker.js` – über den Socket-Proxy: Container-Liste (Name, Compose-Projekt/-Service,
Status, Health, RestartCount) und `stats?stream=false` (CPU %, RAM).
- `sizes.js` – Größe pro App: Ordner unter `/home/webklar/apps/<app>` plus Docker-
Volumes der Container dieser App (`du -sb`, unter `nice -n 19 ionice -c3`).
Außerdem „Aufräumbar“: ungenutzte Images, Build-Cache, gestoppte Container
(`/system/df` über den Proxy).
2. **App-Zuordnung** (`apps.js`): Container → App über das Label
`com.docker.compose.service` bzw. den Containernamen; eine kleine Mapping-Datei
`config/apps.json` erlaubt Umbenennen/Zusammenfassen (z. B. `webklar-ticket-*` →
„Ticketsystem“) und Ordnerzuordnung. Previews erscheinen einzeln.
3. **Speicher** (`store.js`): SQLite (`node:sqlite`) im Volume `serverpuls-data`.
- Tabellen: `host_samples`, `app_samples`, `app_sizes`, `cleanup_samples`,
`alerts` (Zustand), `mail_queue`.
- Aufbewahrung: Rohwerte (5 min) 7 Tage → Stundenmittel/-max 90 Tage →
Tageswerte unbegrenzt. Verdichtung täglich 03:30.
- Erwartete Größe < 100 MB/Jahr.
4. **Regeln** (`rules.js`): reine Funktion `(aktuelle + historische Werte) → Liste aktiver
Befunde {id, level, titel, wert, seit}`. Schwellen in `config/rules.json`.
5. **Alarmierung** (`alerts.js`): vergleicht Befunde mit gespeichertem Zustand, erzeugt
Mails (sofort/täglich/wöchentlich), Wiederholungssperre 24 h, „✅ behoben“-Mail beim
Verschwinden. Zustand in SQLite → Neustart erzeugt keine Doppel-Mails.
6. **Mailer** (`mail.js`): nodemailer, `mail.your-server.de:587` STARTTLS, Login
`server@webklar.com`. Fehlgeschlagene Mails bleiben in `mail_queue` und werden
alle 5 min erneut versucht (max. 24 h).
7. **API** (`api.js`): HTTP-Server Port 8091, Basis `/api/serverpuls`. Jede Anfrage
(außer `/health`) braucht `X-Appwrite-JWT`; der Dienst fragt `/account` bei Appwrite
ab (Cache 60 s) und verlangt Label `admin`, sonst 401/403.
8. **Scheduler** (`main.js`): 5-min-Takt, nächtliche Größenmessung 03:00, Verdichtung
03:30, Medium-Digest täglich 07:00, Low-Wochenübersicht Mo 07:00 (Europe/Berlin).
### Messung
- Alle 5 Minuten: Server gesamt (CPU %, Load 1/5/15, RAM verfügbar, Swap belegt,
Platte `/`, Zombies) und pro Container (CPU %, RAM, Status, Neustarts).
- Täglich 03:00: Größe pro App und „Aufräumbar“-Werte.
- Fällt eine Messung aus, wird **kein** Wert geschrieben (Lücke statt falscher Null).
## Warnregeln
Wichtige Container („kritisch“): Traefik, Appwrite, Kundenportal, Ticketsystem,
ticket-integrations, Gitea, InvoiceNinja, n8n (Liste in `config/rules.json`).
**🔴 Critical – sofortige Mail**
- Platte `/` ≥ 90 %
- RAM verfügbar < 500 MB **und** Swap ≥ 90 %, 10 min lang
- Load (5 min) > 24, 15 min lang
- kritischer Container läuft nicht oder > 3 Neustarts pro Stunde
- Zombies ≥ 100
**🟠 Medium – Sammelmail täglich 07:00**
- Platte `/` ≥ 80 %
- Swap ≥ 75 % oder RAM verfügbar < 1,5 GB, jeweils 1 h lang
- Zombies ≥ 20
- App wächst in 7 Tagen um > 20 % oder > 2 GB
- Load (5 min) > 8, 1 h lang
- nicht-kritischer Container abgestürzt (exited ≠ 0 oder Restart-Schleife)
**🟡 Low – Wochenübersicht Mo 07:00**
- ungenutzte Docker-Images > 10 GB oder Build-Cache > 3 GB
- App wächst in der Woche um > 10 %
- Zombies (≥ 1) halten sich > 24 h
- immer enthalten: Top 5 größte Apps, Top 5 schnellstes Wachstum, Top 5 RAM
**Allgemein**
- Dieselbe Warnung (gleiche `id`) höchstens 1× pro 24 h erneut gemeldet.
- Beim Verschwinden: Mail „✅ behoben“ (in derselben Taktung wie die Stufe; bei
Critical sofort).
- Alle aktiven Warnungen stehen jederzeit auf der Seite.
- Wird eine Warnung höher eingestuft (Medium → Critical), gilt sofort die höhere Stufe.
## API
Alle Antworten JSON, Zeitstempel ISO/UTC.
| Methode + Pfad | Inhalt |
|---|---|
| `GET /health` | ohne Auth; `{ok, lastSample}` |
| `GET /overview` | aktuelle Serverwerte, 24-h-Minikurven, Trend ggü. gestern, aktive Warnungen, Zeitpunkt der letzten Messung |
| `GET /host?metric=cpu\|ram\|swap\|load\|disk\|zombies&range=24h\|7d\|30d\|90d\|1y` | Zeitreihe; Auflösung abhängig vom Zeitraum |
| `GET /apps` | Tabelle: Name, CPU %, RAM, Speicher, Δ 7 T, Δ 30 T, Speicher-Minikurve (30 T) |
| `GET /apps/:id?range=…` | Zeitreihen Speicher, RAM, CPU für eine App |
| `GET /cleanup` | ungenutzte Images, Build-Cache, gestoppte Container mit Größe |
Nur lesend. Es gibt **keine** Endpunkte zum Löschen, Stoppen oder Ändern.
## WOMS-Seite „Serverpuls“
- Navbar-Eintrag mit Pulssymbol, nur für `isAdmin`; roter/oranger Punkt bei aktiver
Critical/Medium-Warnung (aus `/overview`, beim Laden der App und alle 5 min).
- Route `/serverpuls`; Nicht-Admins werden umgeleitet.
- Aufbau:
1. **Kopf mit Ampel**: 🟢 Alles ok / 🟠 n Warnungen / 🔴 Critical, darunter die
aktiven Warnungen (Stufe, seit wann, Wert). Hinweis „letzte Messung vor X Min.“;
gelb, wenn älter als 15 min.
2. **Kacheln**: CPU, Load, RAM, Swap, Platte, Zombies – aktueller Wert, 24-h-Minikurve,
Trend ggü. gestern.
3. **Großes Diagramm** (uPlot): Zeitraum 24 h / 7 T / 30 T / 90 T / 1 J, Wert
wählbar, Tooltip mit exaktem Wert.
4. **Tabelle „Projekte“**: sortierbar, Suchfeld, Δ-Spalten grün/rot mit Pfeil,
Minikurve; Klick öffnet Detaildiagramm der App.
5. **„Aufräumbar“**: reine Anzeige.
- Aktualisiert sich jede Minute, solange sichtbar. Handy: Kacheln untereinander.
- Neue Abhängigkeit: `uplot`. Lockfile wie bisher in einem Wegwerf-`node:20-alpine`
erzeugen (Deploy-Image).
## Betrieb / Deploy
- Code: `/home/webklar/apps/serverpuls/` (eigenes Git-Repo, nur lokal).
- `docker-compose.yml` (Backup vorher): Dienste `serverpuls` (`node:22-alpine`,
`mem_limit: 128m`, `cpus: 0.5`, `init: true`; Host-`/proc` wird als `/host/proc:ro` gemountet,
kein `pid: host`) und `serverpuls-docker-proxy`
(`tecnativa/docker-socket-proxy`, nur lesend: `CONTAINERS=1`, `IMAGES=1`, `SYSTEM=1`
für `/system/df`, `POST=0`), beide im `webklar-network`, ohne Traefik-Route.
- Mounts serverpuls: `/proc:/host/proc:ro`, `/home/webklar/apps:/host/apps:ro`,
`/var/lib/docker/volumes:/host/volumes:ro`, `serverpuls-data:/data`.
- `.env`: `SERVERPULS_SMTP_USER=server@webklar.com`, `SERVERPULS_SMTP_PASS` – vom
Nutzer über eine Erweiterung von `scripts/mail-zugang.py` gesetzt (Claude sieht das
Passwort nicht).
- WOMS-nginx: `location /api/serverpuls/` → `http://serverpuls:8091` (wie
`/api/integrations/`).
- WOMS-Code auf `feature/serverpuls` (Worktree `dev-serverpuls`, Basis `origin/test`).
Live erst nach Push des Nutzers.
## Fehlerbehandlung
- Collector-Teilfehler (z. B. Proxy nicht erreichbar) → betroffene Werte fehlen, Rest
wird gespeichert; Fehler im Log.
- Größenmessung hat Zeitlimit (30 min); bricht sie ab, bleiben die Vortageswerte stehen
und die Seite zeigt deren Datum.
- SMTP-Fehler → `mail_queue`, erneuter Versuch alle 5 min bis 24 h.
- Appwrite nicht erreichbar → API 502, Seite zeigt Fehlerhinweis.
## Tests
- Unit: Regeln (Schwellen, Dauerbedingungen, Hoch-/Herabstufung, Wiederholungssperre,
behoben), Verdichtung, Wachstumsberechnung, `/proc`-Parser mit Beispieldateien.
- API: gemocktes Appwrite (Admin → 200, ohne Label → 403, ohne JWT → 401).
- UI: bewährtes esbuild-/Playwright-Harness unter RAM-Limit mit API-Fixtures.
- Live-Check: Container-RAM < 128 MB, erste Messwerte, Test-Mail an server@.
## Nicht enthalten
- Keine Aktionen (Löschen, Neustarten) aus der Seite heraus.
- Keine KI-Auswertung.
- Keine automatischen Tickets (Warnungen nur per Mail und auf der Seite).
@@ -0,0 +1,178 @@
# Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten – Konzept
Stand 2026-09-30, freigegeben im Gespräch (Abschnitte 1–4).
## Ziel
Wer bei einem Arbeitsblatt eines Kollegen mitgeholfen hat, wird als **Unterstützung** eingetragen:
Person und Tätigkeit. Die Person, die das Arbeitsblatt angelegt hat, behält 100 % der Zeit; jede
unterstützende Person bekommt zusätzlich **50 % der Arbeitszeit, aufgerundet auf volle 15 Minuten**.
Die Unterstützungszeit wird dem Kunden berechnet und im Kundenportal einzeln ausgewiesen.
Gleichzeitig werden Arbeitszeiten **überall nur noch in 15-Minuten-Schritten** erfasst. Das hebt die
Entscheidung vom 2026-09-26 („ganze Minuten, keine 15er-Schritte“) auf.
Die Verträge (Abo-Bedingungen, AGB) halten Rundung und Unterstützung fest.
Nicht im Umfang: Auswertung der Stunden je Mitarbeiter, Umrechnung alter Arbeitsblätter,
Rechnungserstellung.
## Rechenregel
```
supportMinutes(totalTime) = ceil(totalTime / 2 / 15) * 15 // 0 bei totalTime <= 0
worksheetMinutes(ws) = totalTime + Anzahl Unterstützer * supportMinutes(totalTime)
```
Beispiele: 15 → 15, 30 → 15, 45 → 30, 60 → 30, 75 → 45, 70 (alt) → 45.
Kommentare und Git-Pushs ohne nachgetragene Zeit haben `totalTime` 0, also auch 0 Unterstützung.
Die Regel steht in beiden Systemen (WOMS und Portal) mit denselben Testfällen. Die Minuten der
Unterstützung werden **nie gespeichert**, sondern immer aus `totalTime` berechnet. Ändert sich die
Arbeitszeit, stimmt die Unterstützung automatisch.
## Datenmodell
Appwrite `woms-database`, Collection `worksheets`, neues Attribut:
- `supporters` – String, nicht Pflicht, Größe 4000, JSON-Array:
`[{ "employeeId": "<Appwrite-User-ID>", "name": "Andrej Stevanovski", "task": "Farbkonzept" }]`
Regeln: `task` ist Pflicht (1–200 Zeichen, getrimmt). Jede `employeeId` höchstens einmal. Die Person,
die das Arbeitsblatt angelegt hat, darf nicht als Unterstützung stehen (bei Git-Pushs: der Mitarbeiter,
dem der Push gehört, siehe `isWorksheetCreator`). `name` wird beim Eintragen gespeichert, damit die
Zeile auch nach dem Löschen eines Mitarbeiters lesbar bleibt.
Das Attribut legt ein Skript `scripts/setup-supporters.mjs` an (idempotent). Der Nutzer führt es aus,
weil Claude den Appwrite-Schlüssel nicht verwenden darf.
## WOMS (Ticketsystem)
### 15-Minuten-Schritte
Betroffen: `CreateWorksheetModal` (neues Arbeitsblatt), `WorksheetTimesModal` („Zeiten bearbeiten“ und
„Zeit nachtragen“ bei Git).
- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 12 h (48 Werte).
Kein freies Eintippen mehr.
- Beginn und Ende bleiben freie Uhrzeiten (bei Git ist das Ende die Uhrzeit des Pushs). Die
automatisch berechnete Zeit (`minutesBetween`) wird auf den nächsten 15-Minuten-Schritt
**aufgerundet**; danach kann ein anderer Wert gewählt werden. Dauer 0 → 15 min vorausgewählt.
- Hat ein altes Arbeitsblatt eine Zeit außerhalb des Rasters (z. B. 70), ist beim Bearbeiten der
nächsthöhere Schritt (75) vorausgewählt.
- Beim Speichern wird geprüft, dass `totalTime` ein positives Vielfaches von 15 ist (außer bei
„Nur Kommentar“, dort 0).
- `useWorksheets.createWorksheet` rechnet eine fehlende Zeit ebenfalls aufgerundet.
### Unterstützung eintragen
**Person, die das Arbeitsblatt angelegt hat** (`canEditWorksheetTimes`): im Formular „Zeiten
bearbeiten“ und beim neuen Arbeitsblatt der Haken **„Unterstützung“**. Darunter Zeilen mit
*Mitarbeiter* (Auswahl aus `employees`, ohne sich selbst und ohne schon gewählte) und *Wobei?*
(Pflichttext), „+ weitere Person“, Entfernen je Zeile. Haken aus = Liste leeren.
**Alle anderen Mitarbeiter:** bei fremden Arbeitsblättern mit Arbeitszeit der Knopf
**„Ich habe unterstützt“**. Kleines Formular nur mit *Wobei?*. Ist man schon eingetragen, heißt der
Knopf „Meine Unterstützung bearbeiten“ und man kann die Tätigkeit ändern oder den Eintrag entfernen.
Andere Einträge und die Zeiten sind nicht änderbar.
Neue Hilfen in `lib/ticketForm.js` (oder `lib/support.js`): `supportMinutes`, `worksheetMinutes`,
`parseSupporters` (fehlertolerant, kaputtes JSON → leere Liste), `canAddOwnSupport(ws, user, employee)`
(nicht Ersteller, Arbeitsblatt hat Arbeitszeit), `upsertOwnSupport`, `removeOwnSupport`.
Unterstützung ist nur möglich bei Arbeitsblättern mit Arbeitszeit: nicht bei reinen Kommentaren, bei
Git-Pushs erst nach „Zeit nachtragen“.
Speichern: `updateWorksheet` patcht `{ supporters }`. „Ich habe unterstützt“ liest das Dokument
vorher frisch und schreibt nur den eigenen Eintrag um, damit gleichzeitige Einträge anderer nicht
überschrieben werden.
Berechtigungen: wie bisher nur in der Oberfläche durchgesetzt (die Collection erlaubt Update für
Benutzer). Keine Änderung an den Appwrite-Rechten.
### Anzeige
- `WorksheetList`: unter dem Arbeitsblatt je Unterstützung
„↳ Unterstützung Andrej · Farbkonzept · 30 min“.
- `WorksheetSummary` „Arbeitszeit gesamt“ und `useWorksheets` Summen verwenden `worksheetMinutes`.
- Der Abo-Haken („+ Abo“, `countsToPlan`) gilt für das ganze Arbeitsblatt einschließlich Unterstützung.
## Kundenportal
- Gleiche Rechenregel als `server/services/worksheetMinutes.js` (oder vorhandenes Modul) mit
`supportMinutes`, `worksheetMinutes`, `parseSupporters`. Kaputtes JSON → Eintrag überspringen und
loggen, nie Absturz.
- **Stundenkonto** (`hourLedger.js`): Verbrauch eines Abo-Arbeitsblatts = `worksheetMinutes(ws)`
statt `totalTime`. Die Zeile im Verlauf bekommt den Zusatz „inkl. 30 min Unterstützung“.
- **„Arbeiten am Projekt“** (`customerProjectView.js`, `public/app.js`): jeder Eintrag bekommt
`supports: [{ name, task, minutes }]`, dargestellt als eigene Zeilen unter dem Schritt:
„↳ Unterstützung Andrej: Farbkonzept · 30 min“. Werden mehrere Git-Pushs eines Tages zu
„Website aktualisiert“ zusammengefasst, hängen alle Unterstützungen unter diesem Eintrag.
- **Gesamtzeit oben:** im Kopf der Projektdetails (neben Auftragsnummer und Status) die Zeile
„Arbeitszeit gesamt: 4 h 30 min“ = Summe aller Schritte inkl. Unterstützung. Ohne Auftrag steht sie
oben im Abschnitt „Arbeiten am Projekt“. Bei 0 wird sie nicht gezeigt.
- `customerActivity.js` gibt `supporters` mit weiter, wo `totalTime` weitergegeben wird.
- **Push-Zeitmail** (Spec af7ecce, noch nicht gebaut): „andere Zeit“ nur in 15-Minuten-Schritten;
wird in jener Spec nachgetragen.
## Verträge
Stand heute: Abo-Bedingungen Version 4 (live seit 2026-09-30) § 4 enthält schon „aufgerundet auf volle
15 Minuten“ / „in Schritten von 15 Minuten“, aber nichts zur Unterstützung. AGB § 9 sagt nur
„Die Vergütung ergibt sich aus dem Angebot“.
- **Abo-Bedingungen Version 5**, § 4 neuer Satz nach der Rundungsregel (Entwurf):
„Wirkt an einem Arbeitsschritt eine weitere Person unterstützend mit, etwa bei Gestaltung,
Abstimmung oder Prüfung, rechnen wir für sie die Hälfte der Arbeitszeit dieses Arbeitsschritts an,
aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede von ihnen.
Im Kundenportal weisen wir jeden Arbeitsschritt mit seiner Arbeitszeit und jede Unterstützung mit
Person, Tätigkeit und angerechneter Zeit einzeln aus.“
- **AGB § 9** neuer Absatz 2, nur für Abrechnung nach Aufwand (Festpreis-Angebote unberührt), Regel
vollständig ausgeschrieben (kein Verweis auf die Abo-Bedingungen):
„Wird nach Aufwand abgerechnet, erfassen wir die Arbeitszeit je Arbeitsschritt in Schritten von
15 Minuten; angefangene 15 Minuten runden wir auf. Wirkt eine weitere Person unterstützend mit,
rechnen wir für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle
15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede
Unterstützung ist im Kundenportal einsehbar.“
Wird mit dem offenen § 11-Haftungsentwurf (webklar.com f40e889, fix/agb-haftung) zu **einer** neuen
AGB-Version gebündelt.
- Alle Stellen, die die Texte wiederholen (PDF-Anhang der Vertragsmails, `vertragsunterlagen/`,
Kalkulation, Portal-Texte), werden abgeglichen. Beide Texte kommen in WOMS „Rechtliches“ als neue
Version.
- Laufende Abos bleiben auf ihrer Version. Einschätzung (keine Rechtsberatung): Version 4 rechnet die
„tatsächlich aufgewendete Zeit“ ab; bei zwei Personen fällt mehr Zeit an, als mit 50 % berechnet
wird, die Unterstützung ist also gedeckt.
- Die endgültigen Texte legt Claude dem Nutzer vor dem Livegang einzeln zur Freigabe vor.
## Alte Daten und Fehlerfälle
- Alte Arbeitsblätter bleiben unverändert (keine Massen-Umrechnung); 70 Minuten bleiben 70 Minuten,
bis jemand das Blatt bearbeitet.
- Ohne `supporters` = keine Unterstützung.
- Gelöschter Mitarbeiter: gespeicherter Name bleibt, Zeile wird weiter gezeigt und berechnet.
- Doppelte Person / Ersteller als Unterstützung: Oberfläche verhindert es; beim Lesen werden Duplikate
ignoriert.
- Arbeitszeit 0 oder „Nur Kommentar“: Unterstützung zählt 0 (Einträge bleiben gespeichert).
## Livegang (Reihenfolge)
1. Nutzer führt `scripts/setup-supporters.mjs` aus (Attribut `supporters`).
2. Kundenportal live (Push `main`), damit keine Unterstützung eingetragen werden kann, die der Kunde
nicht sieht.
3. WOMS live (Push `feature/unterstuetzung:test`).
4. Verträge: Abo-Bedingungen v5 (Portal) und AGB (webklar.com) nach Freigabe der Texte; Versionen in
„Rechtliches“ eintragen.
Nichts wird ohne das OK des Nutzers gepusht.
## Tests
- Rechenregel in beiden Systemen mit denselben Fällen (15, 30, 45, 60, 75, 70, 0, Kommentar).
- WOMS: Unit-Tests (`npm test`) für Rechnung, `parseSupporters`, Berechtigungen
(`canAddOwnSupport`, Ersteller/Nicht-Ersteller, Git-Push), eigener Eintrag ändern/entfernen.
Playwright mit nachgebautem Appwrite: 15-Minuten-Auswahl und Aufrunden, Haken „Unterstützung“ mit
zwei Personen, „Ich habe unterstützt“, Anzeige in Liste und Summe.
- Portal: Tests für `customerWorkEntries` (Unterstützungs-Zeilen, zusammengefasste Git-Tage),
Gesamtzeit, Stundenkonto mit Unterstützung, kaputtes JSON.
- Vor dem Livegang: Build wie beim Deploy (`docker run node:20-alpine`), Blick auf echte Daten nur
lesend im Chrome des Nutzers.
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webklar WOMS 2.0</title>
<link href="https://fonts.googleapis.com/css?family=Lato|Open+Sans" rel="stylesheet">
</head>
<body>
<div id="root"></div>
+31 -31
View File
@@ -1,109 +1,109 @@
{
"hash": "ba7c7150",
"configHash": "6a220e5a",
"lockfileHash": "a10f8d29",
"browserHash": "db384cdc",
"hash": "91e90cf6",
"configHash": "eaf678ca",
"lockfileHash": "4b2b8d7d",
"browserHash": "114e306f",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "d41cd819",
"fileHash": "c1394b31",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "02c7f745",
"fileHash": "e0cfaa25",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "beec6a15",
"fileHash": "4e6552af",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "f6c3e40e",
"fileHash": "326b9f6e",
"needsInterop": true
},
"@tabler/icons-react": {
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
"file": "@tabler_icons-react.js",
"fileHash": "11d925b5",
"fileHash": "ef17992e",
"needsInterop": false
},
"appwrite": {
"src": "../../appwrite/dist/esm/sdk.js",
"file": "appwrite.js",
"fileHash": "e5052173",
"fileHash": "e3207492",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "7fc4f217",
"fileHash": "438277fe",
"needsInterop": false
},
"date-fns": {
"src": "../../date-fns/esm/index.js",
"file": "date-fns.js",
"fileHash": "371588f0",
"fileHash": "c8cdb3c6",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../date-fns/esm/locale/index.js",
"file": "date-fns_locale.js",
"fileHash": "927376cc",
"fileHash": "affdbcf7",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "03c47f11",
"fileHash": "d06adb28",
"needsInterop": false
},
"postprocessing": {
"src": "../../postprocessing/build/index.js",
"file": "postprocessing.js",
"fileHash": "6a1af3d4",
"fileHash": "a05d80d0",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "13d89711",
"fileHash": "35e13db7",
"needsInterop": true
},
"react-icons/fa": {
"src": "../../react-icons/fa/index.esm.js",
"file": "react-icons_fa.js",
"fileHash": "ce6891d0",
"fileHash": "b39d751a",
"needsInterop": false
},
"react-icons/fa6": {
"src": "../../react-icons/fa6/index.esm.js",
"file": "react-icons_fa6.js",
"fileHash": "836a5ede",
"fileHash": "ee91d98a",
"needsInterop": false
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "65b7bd91",
"fileHash": "96b74880",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "8ea254d4",
"fileHash": "422eebfe",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "c500eef6",
"fileHash": "7ea78180",
"needsInterop": false
}
},
@@ -111,20 +111,20 @@
"chunk-IFCYBMKG": {
"file": "chunk-IFCYBMKG.js"
},
"chunk-7VTGDDTZ": {
"file": "chunk-7VTGDDTZ.js"
"chunk-DQJKJRV5": {
"file": "chunk-DQJKJRV5.js"
},
"chunk-TDH2IRYZ": {
"file": "chunk-TDH2IRYZ.js"
"chunk-PJEEZAML": {
"file": "chunk-PJEEZAML.js"
},
"chunk-NMLHVZ76": {
"file": "chunk-NMLHVZ76.js"
"chunk-6PXSGDAH": {
"file": "chunk-6PXSGDAH.js"
},
"chunk-QRULMDK5": {
"file": "chunk-QRULMDK5.js"
"chunk-DRWLMN53": {
"file": "chunk-DRWLMN53.js"
},
"chunk-FSI7PPCM": {
"file": "chunk-FSI7PPCM.js"
"chunk-SJKHQ62W": {
"file": "chunk-SJKHQ62W.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../isomorphic-form-data/lib/browser.js", "../../cross-fetch/dist/browser-ponyfill.js", "../../appwrite/node_modules/tslib/tslib.es6.js", "../../appwrite/src/service.ts", "../../appwrite/src/query.ts", "../../appwrite/src/client.ts", "../../appwrite/src/services/account.ts", "../../appwrite/src/services/avatars.ts", "../../appwrite/src/services/databases.ts", "../../appwrite/src/services/functions.ts", "../../appwrite/src/services/graphql.ts", "../../appwrite/src/services/locale.ts", "../../appwrite/src/services/storage.ts", "../../appwrite/src/services/teams.ts", "../../appwrite/src/permission.ts", "../../appwrite/src/role.ts", "../../appwrite/src/id.ts"],
"sourcesContent": ["module.exports = window.FormData\r\n", "var global = typeof self !== 'undefined' ? self : this;\r\nvar __self__ = (function () {\r\nfunction F() {\r\nthis.fetch = false;\r\nthis.DOMException = global.DOMException\r\n}\r\nF.prototype = global;\r\nreturn new F();\r\n})();\r\n(function(self) {\r\n\r\nvar irrelevant = (function (exports) {\r\n\r\n var support = {\r\n searchParams: 'URLSearchParams' in self,\r\n iterable: 'Symbol' in self && 'iterator' in Symbol,\r\n blob:\r\n 'FileReader' in self &&\r\n 'Blob' in self &&\r\n (function() {\r\n try {\r\n new Blob();\r\n return true\r\n } catch (e) {\r\n return false\r\n }\r\n })(),\r\n formData: 'FormData' in self,\r\n arrayBuffer: 'ArrayBuffer' in self\r\n };\r\n\r\n function isDataView(obj) {\r\n return obj && DataView.prototype.isPrototypeOf(obj)\r\n }\r\n\r\n if (support.arrayBuffer) {\r\n var viewClasses = [\r\n '[object Int8Array]',\r\n '[object Uint8Array]',\r\n '[object Uint8ClampedArray]',\r\n '[object Int16Array]',\r\n '[object Uint16Array]',\r\n '[object Int32Array]',\r\n '[object Uint32Array]',\r\n '[object Float32Array]',\r\n '[object Float64Array]'\r\n ];\r\n\r\n var isArrayBufferView =\r\n ArrayBuffer.isView ||\r\n function(obj) {\r\n return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1\r\n };\r\n }\r\n\r\n function normalizeName(name) {\r\n if (typeof name !== 'string') {\r\n name = String(name);\r\n }\r\n if (/[^a-z0-9\\-#$%&'*+.^_`|~]/i.test(name)) {\r\n throw new TypeError('Invalid character in header field name')\r\n }\r\n return name.toLowerCase()\r\n }\r\n\r\n function normalizeValue(value) {\r\n if (typeof value !== 'string') {\r\n value = String(value);\r\n }\r\n return value\r\n }\r\n\r\n // Build a destructive iterator for the value list\r\n function iteratorFor(items) {\r\n var iterator = {\r\n next: function() {\r\n var value = items.shift();\r\n return {done: value === undefined, value: value}\r\n }\r\n };\r\n\r\n if (support.iterable) {\r\n iterator[Symbol.iterator] = function() {\r\n return iterator\r\n };\r\n }\r\n\r\n return iterator\r\n }\r\n\r\n function Headers(headers) {\r\n this.map = {};\r\n\r\n if (headers instanceof Headers) {\r\n headers.forEach(function(value, name) {\r\n this.append(name, value);\r\n }, this);\r\n } else if (Array.isArray(headers)) {\r\n headers.forEach(function(header) {\r\n this.append(header[0], header[1]);\r\n }, this);\r\n } else if (headers) {\r\n Object.getOwnPropertyNames(headers).forEach(function(name) {\r\n this.append(name, headers[name]);\r\n }, this);\r\n }\r\n }\r\n\r\n Headers.prototype.append = function(name, value) {\r\n name = normalizeName(name);\r\n value = normalizeValue(value);\r\n var oldValue = this.map[name];\r\n this.map[name] = oldValue ? oldValue + ', ' + value : value;\r\n };\r\n\r\n Headers.prototype['delete'] = function(name) {\r\n delete this.map[normalizeName(name)];\r\n };\r\n\r\n Headers.prototype.get = function(name) {\r\n name = normalizeName(name);\r\n return this.has(name) ? this.map[name] : null\r\n };\r\n\r\n Headers.prototype.has = function(name) {\r\n return this.map.hasOwnProperty(normalizeName(name))\r\n };\r\n\r\n Headers.prototype.set = function(name, value) {\r\n this.map[normalizeName(name)] = normalizeValue(value);\r\n };\r\n\r\n Headers.prototype.forEach = function(callback, thisArg) {\r\n for (var name in this.map) {\r\n if (this.map.hasOwnProperty(name)) {\r\n callback.call(thisArg, this.map[name], name, this);\r\n }\r\n }\r\n };\r\n\r\n Headers.prototype.keys = function() {\r\n var items = [];\r\n this.forEach(function(value, name) {\r\n items.push(name);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n Headers.prototype.values = function() {\r\n var items = [];\r\n this.forEach(function(value) {\r\n items.push(value);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n Headers.prototype.entries = function() {\r\n var items = [];\r\n this.forEach(function(value, name) {\r\n items.push([name, value]);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n if (support.iterable) {\r\n Headers.prototype[Symbol.iterator] = Headers.prototype.entries;\r\n }\r\n\r\n function consumed(body) {\r\n if (body.bodyUsed) {\r\n return Promise.reject(new TypeError('Already read'))\r\n }\r\n body.bodyUsed = true;\r\n }\r\n\r\n function fileReaderReady(reader) {\r\n return new Promise(function(resolve, reject) {\r\n reader.onload = function() {\r\n resolve(reader.result);\r\n };\r\n reader.onerror = function() {\r\n rejLine truncated
"sourcesContent": ["module.exports = window.FormData\n", "var global = typeof self !== 'undefined' ? self : this;\nvar __self__ = (function () {\nfunction F() {\nthis.fetch = false;\nthis.DOMException = global.DOMException\n}\nF.prototype = global;\nreturn new F();\n})();\n(function(self) {\n\nvar irrelevant = (function (exports) {\n\n var support = {\n searchParams: 'URLSearchParams' in self,\n iterable: 'Symbol' in self && 'iterator' in Symbol,\n blob:\n 'FileReader' in self &&\n 'Blob' in self &&\n (function() {\n try {\n new Blob();\n return true\n } catch (e) {\n return false\n }\n })(),\n formData: 'FormData' in self,\n arrayBuffer: 'ArrayBuffer' in self\n };\n\n function isDataView(obj) {\n return obj && DataView.prototype.isPrototypeOf(obj)\n }\n\n if (support.arrayBuffer) {\n var viewClasses = [\n '[object Int8Array]',\n '[object Uint8Array]',\n '[object Uint8ClampedArray]',\n '[object Int16Array]',\n '[object Uint16Array]',\n '[object Int32Array]',\n '[object Uint32Array]',\n '[object Float32Array]',\n '[object Float64Array]'\n ];\n\n var isArrayBufferView =\n ArrayBuffer.isView ||\n function(obj) {\n return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1\n };\n }\n\n function normalizeName(name) {\n if (typeof name !== 'string') {\n name = String(name);\n }\n if (/[^a-z0-9\\-#$%&'*+.^_`|~]/i.test(name)) {\n throw new TypeError('Invalid character in header field name')\n }\n return name.toLowerCase()\n }\n\n function normalizeValue(value) {\n if (typeof value !== 'string') {\n value = String(value);\n }\n return value\n }\n\n // Build a destructive iterator for the value list\n function iteratorFor(items) {\n var iterator = {\n next: function() {\n var value = items.shift();\n return {done: value === undefined, value: value}\n }\n };\n\n if (support.iterable) {\n iterator[Symbol.iterator] = function() {\n return iterator\n };\n }\n\n return iterator\n }\n\n function Headers(headers) {\n this.map = {};\n\n if (headers instanceof Headers) {\n headers.forEach(function(value, name) {\n this.append(name, value);\n }, this);\n } else if (Array.isArray(headers)) {\n headers.forEach(function(header) {\n this.append(header[0], header[1]);\n }, this);\n } else if (headers) {\n Object.getOwnPropertyNames(headers).forEach(function(name) {\n this.append(name, headers[name]);\n }, this);\n }\n }\n\n Headers.prototype.append = function(name, value) {\n name = normalizeName(name);\n value = normalizeValue(value);\n var oldValue = this.map[name];\n this.map[name] = oldValue ? oldValue + ', ' + value : value;\n };\n\n Headers.prototype['delete'] = function(name) {\n delete this.map[normalizeName(name)];\n };\n\n Headers.prototype.get = function(name) {\n name = normalizeName(name);\n return this.has(name) ? this.map[name] : null\n };\n\n Headers.prototype.has = function(name) {\n return this.map.hasOwnProperty(normalizeName(name))\n };\n\n Headers.prototype.set = function(name, value) {\n this.map[normalizeName(name)] = normalizeValue(value);\n };\n\n Headers.prototype.forEach = function(callback, thisArg) {\n for (var name in this.map) {\n if (this.map.hasOwnProperty(name)) {\n callback.call(thisArg, this.map[name], name, this);\n }\n }\n };\n\n Headers.prototype.keys = function() {\n var items = [];\n this.forEach(function(value, name) {\n items.push(name);\n });\n return iteratorFor(items)\n };\n\n Headers.prototype.values = function() {\n var items = [];\n this.forEach(function(value) {\n items.push(value);\n });\n return iteratorFor(items)\n };\n\n Headers.prototype.entries = function() {\n var items = [];\n this.forEach(function(value, name) {\n items.push([name, value]);\n });\n return iteratorFor(items)\n };\n\n if (support.iterable) {\n Headers.prototype[Symbol.iterator] = Headers.prototype.entries;\n }\n\n function consumed(body) {\n if (body.bodyUsed) {\n return Promise.reject(new TypeError('Already read'))\n }\n body.bodyUsed = true;\n }\n\n function fileReaderReady(reader) {\n return new Promise(function(resolve, reject) {\n reader.onload = function() {\n resolve(reader.result);\n };\n reader.onerror = function() {\n reject(reader.error);\n };\n })\n }\n\n function readBlobAsArrayBuffer(blob) {\n var reader = new FileReader();\n var promise = fileReaderReady(reader);\n reader.readAsArrayBuffer(blob);\n return promise\n }\n\n function readBlobAsText(blob) {\n var reader = new FileReader();\n var promise = fileReaderReady(reader);\n reader.readAsTLine truncated
"mappings": "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 truncated
"names": ["self", "exports", "fetch", "_b", "_a"]
}
+1 -1
View File
@@ -256,7 +256,7 @@ import {
weeksToDays,
yearsToMonths,
yearsToQuarters
} from "./chunk-FSI7PPCM.js";
} from "./chunk-SJKHQ62W.js";
import "./chunk-G3PMV62Z.js";
export {
add,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
requiredArgs,
startOfUTCWeek,
toDate
} from "./chunk-FSI7PPCM.js";
} from "./chunk-SJKHQ62W.js";
import "./chunk-G3PMV62Z.js";
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../date-fns/esm/locale/af/_lib/formatDistance/index.js", "../../date-fns/esm/locale/af/_lib/formatLong/index.js", "../../date-fns/esm/locale/af/_lib/formatRelative/index.js", "../../date-fns/esm/locale/af/_lib/localize/index.js", "../../date-fns/esm/locale/af/_lib/match/index.js", "../../date-fns/esm/locale/af/index.js", "../../date-fns/esm/locale/ar/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar/_lib/localize/index.js", "../../date-fns/esm/locale/ar/_lib/match/index.js", "../../date-fns/esm/locale/ar/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/localize/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/match/index.js", "../../date-fns/esm/locale/ar-DZ/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-EG/_lib/localize/index.js", "../../date-fns/esm/locale/ar-EG/_lib/match/index.js", "../../date-fns/esm/locale/ar-EG/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-MA/_lib/localize/index.js", "../../date-fns/esm/locale/ar-MA/_lib/match/index.js", "../../date-fns/esm/locale/ar-MA/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-SA/_lib/localize/index.js", "../../date-fns/esm/locale/ar-SA/_lib/match/index.js", "../../date-fns/esm/locale/ar-SA/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-TN/_lib/localize/index.js", "../../date-fns/esm/locale/ar-TN/_lib/match/index.js", "../../date-fns/esm/locale/ar-TN/index.js", "../../date-fns/esm/locale/az/_lib/formatDistance/index.js", "../../date-fns/esm/locale/az/_lib/formatLong/index.js", "../../date-fns/esm/locale/az/_lib/formatRelative/index.js", "../../date-fns/esm/locale/az/_lib/localize/index.js", "../../date-fns/esm/locale/az/_lib/match/index.js", "../../date-fns/esm/locale/az/index.js", "../../date-fns/esm/locale/be/_lib/formatDistance/index.js", "../../date-fns/esm/locale/be/_lib/formatLong/index.js", "../../date-fns/esm/_lib/isSameUTCWeek/index.js", "../../date-fns/esm/locale/be/_lib/formatRelative/index.js", "../../date-fns/esm/locale/be/_lib/localize/index.js", "../../date-fns/esm/locale/be/_lib/match/index.js", "../../date-fns/esm/locale/be/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatDistance/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatLong/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatRelative/index.js", "../../date-fns/esm/locale/be-tarask/_lib/localize/index.js", "../../date-fns/esm/locale/be-tarask/_lib/match/index.js", "../../date-fns/esm/locale/be-tarask/index.js", "../../date-fns/esm/locale/bg/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bg/_lib/formatLong/index.js", "../../date-fns/esm/locale/bg/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bg/_lib/localize/index.js", "../../date-fns/esm/locale/bg/_lib/match/index.js", "../../date-fns/esm/locale/bg/index.js", "../../date-fns/esm/locale/bn/_lib/localize/index.js", "../../date-fns/esm/locale/bn/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bn/_lib/formatLong/index.js", "../../date-fns/esm/locale/bn/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bn/_lib/match/index.js", "../../date-fns/esm/locale/bn/index.js", "../../date-fns/esm/locale/bs/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bs/_lib/formatLong/index.js", "../../date-fns/esm/locale/bs/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bs/_lib/localize/index.js", "../../date-fns/esm/locale/bs/_lib/match/index.js", "../../date-fns/esm/locale/bs/index.js", "../../date-fns/esm/locale/ca/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ca/_lib/formatLong/index.js", "../../date-fns/esm/locale/ca/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ca/_lib/localize/index.js", "../../date-fns/esm/locale/ca/_lib/match/index.js", "../../date-fns/esm/locale/ca/index.js", "../../date-fns/esm/locale/cs/_lib/formatDistance/index.js", "../../date-fns/esm/locale/cs/_lib/formatLong/index.js", "../../date-fns/esm/locale/cs/_lib/formatRelative/index.js", "../../date-fns/esm/locale/cs/_lib/localize/inLine truncated
"sourcesContent": ["var formatDistanceLocale = {\r\n lessThanXSeconds: {\r\n one: \"minder as 'n sekonde\",\r\n other: 'minder as {{count}} sekondes'\r\n },\r\n xSeconds: {\r\n one: '1 sekonde',\r\n other: '{{count}} sekondes'\r\n },\r\n halfAMinute: \"'n halwe minuut\",\r\n lessThanXMinutes: {\r\n one: \"minder as 'n minuut\",\r\n other: 'minder as {{count}} minute'\r\n },\r\n xMinutes: {\r\n one: \"'n minuut\",\r\n other: '{{count}} minute'\r\n },\r\n aboutXHours: {\r\n one: 'ongeveer 1 uur',\r\n other: 'ongeveer {{count}} ure'\r\n },\r\n xHours: {\r\n one: '1 uur',\r\n other: '{{count}} ure'\r\n },\r\n xDays: {\r\n one: '1 dag',\r\n other: '{{count}} dae'\r\n },\r\n aboutXWeeks: {\r\n one: 'ongeveer 1 week',\r\n other: 'ongeveer {{count}} weke'\r\n },\r\n xWeeks: {\r\n one: '1 week',\r\n other: '{{count}} weke'\r\n },\r\n aboutXMonths: {\r\n one: 'ongeveer 1 maand',\r\n other: 'ongeveer {{count}} maande'\r\n },\r\n xMonths: {\r\n one: '1 maand',\r\n other: '{{count}} maande'\r\n },\r\n aboutXYears: {\r\n one: 'ongeveer 1 jaar',\r\n other: 'ongeveer {{count}} jaar'\r\n },\r\n xYears: {\r\n one: '1 jaar',\r\n other: '{{count}} jaar'\r\n },\r\n overXYears: {\r\n one: 'meer as 1 jaar',\r\n other: 'meer as {{count}} jaar'\r\n },\r\n almostXYears: {\r\n one: 'byna 1 jaar',\r\n other: 'byna {{count}} jaar'\r\n }\r\n};\r\nvar formatDistance = function formatDistance(token, count, options) {\r\n var result;\r\n var tokenValue = formatDistanceLocale[token];\r\n if (typeof tokenValue === 'string') {\r\n result = tokenValue;\r\n } else if (count === 1) {\r\n result = tokenValue.one;\r\n } else {\r\n result = tokenValue.other.replace('{{count}}', String(count));\r\n }\r\n if (options !== null && options !== void 0 && options.addSuffix) {\r\n if (options.comparison && options.comparison > 0) {\r\n return 'oor ' + result;\r\n } else {\r\n return result + ' gelede';\r\n }\r\n }\r\n return result;\r\n};\r\nexport default formatDistance;", "import buildFormatLongFn from \"../../../_lib/buildFormatLongFn/index.js\";\r\nvar dateFormats = {\r\n full: 'EEEE, d MMMM yyyy',\r\n long: 'd MMMM yyyy',\r\n medium: 'd MMM yyyy',\r\n short: 'yyyy/MM/dd'\r\n};\r\nvar timeFormats = {\r\n full: 'HH:mm:ss zzzz',\r\n long: 'HH:mm:ss z',\r\n medium: 'HH:mm:ss',\r\n short: 'HH:mm'\r\n};\r\nvar dateTimeFormats = {\r\n full: \"{{date}} 'om' {{time}}\",\r\n long: \"{{date}} 'om' {{time}}\",\r\n medium: '{{date}}, {{time}}',\r\n short: '{{date}}, {{time}}'\r\n};\r\nvar formatLong = {\r\n date: buildFormatLongFn({\r\n formats: dateFormats,\r\n defaultWidth: 'full'\r\n }),\r\n time: buildFormatLongFn({\r\n formats: timeFormats,\r\n defaultWidth: 'full'\r\n }),\r\n dateTime: buildFormatLongFn({\r\n formats: dateTimeFormats,\r\n defaultWidth: 'full'\r\n })\r\n};\r\nexport default formatLong;", "var formatRelativeLocale = {\r\n lastWeek: \"'verlede' eeee 'om' p\",\r\n yesterday: \"'gister om' p\",\r\n today: \"'vandag om' p\",\r\n tomorrow: \"'môre om' p\",\r\n nextWeek: \"eeee 'om' p\",\r\n other: 'P'\r\n};\r\nvar formatRelative = function formatRelative(token, _date, _baseDate, _options) {\r\n return formatRelativeLocale[token];\r\n};\r\nexport default formatRelative;", "import buildLocalizeFn from \"../../../_lib/buildLocalizeFn/index.js\";\r\nvar eraValues = {\r\n narrow: ['vC', 'nC'],\r\n abbreviated: ['vC', 'nC'],\r\n wide: ['voor Christus', 'na Christus']\r\n};\r\nvar quarterValues = {\r\n narrow: ['1', '2', '3', '4'],\r\n abbreviated: ['K1', 'K2', 'K3', 'K4'],\r\n wide: ['1ste kwartaal', '2de kwartaal', '3de kwartaal', '4de kwartaal']\r\n};\r\nvar monthValues = {\r\n narrow: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'],\r\n abbreviated: ['Jan', 'Feb', 'Mrt', 'Apr', 'Mei', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Des'],\r\n wide: ['Januarie', 'Februarie', 'Maart', 'April', 'Mei', 'Junie', 'Julie', 'Augustus', 'September', 'Oktober', 'November', 'Desember']\r\n};\r\nvar dayValues = {\r\n narrow: ['S', 'M', 'D', 'W', 'D', 'V', 'S'],\r\n short: ['So', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Sa'],\r\n abbreviated: ['Son', 'Maa', 'Din', 'Woe', 'Don', 'Vry', 'Sat'],\r\n wide: ['Sondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrydag', 'Saterdag']\r\n};\r\nvar dayPeriodValues = {\r\n narrow: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\n morning: 'oggend',\r\n afternoon: 'middag',\r\n evening: 'laat middag',\r\n night: 'aand'\r\n },\r\n abbreviated: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\n morning: 'oggend',\r\n afternoon: 'middag',\r\n evening: 'laat middag',\r\n night: 'aand'\r\n },\r\n wide: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\nLine truncated
"sourcesContent": ["var formatDistanceLocale = {\n lessThanXSeconds: {\n one: \"minder as 'n sekonde\",\n other: 'minder as {{count}} sekondes'\n },\n xSeconds: {\n one: '1 sekonde',\n other: '{{count}} sekondes'\n },\n halfAMinute: \"'n halwe minuut\",\n lessThanXMinutes: {\n one: \"minder as 'n minuut\",\n other: 'minder as {{count}} minute'\n },\n xMinutes: {\n one: \"'n minuut\",\n other: '{{count}} minute'\n },\n aboutXHours: {\n one: 'ongeveer 1 uur',\n other: 'ongeveer {{count}} ure'\n },\n xHours: {\n one: '1 uur',\n other: '{{count}} ure'\n },\n xDays: {\n one: '1 dag',\n other: '{{count}} dae'\n },\n aboutXWeeks: {\n one: 'ongeveer 1 week',\n other: 'ongeveer {{count}} weke'\n },\n xWeeks: {\n one: '1 week',\n other: '{{count}} weke'\n },\n aboutXMonths: {\n one: 'ongeveer 1 maand',\n other: 'ongeveer {{count}} maande'\n },\n xMonths: {\n one: '1 maand',\n other: '{{count}} maande'\n },\n aboutXYears: {\n one: 'ongeveer 1 jaar',\n other: 'ongeveer {{count}} jaar'\n },\n xYears: {\n one: '1 jaar',\n other: '{{count}} jaar'\n },\n overXYears: {\n one: 'meer as 1 jaar',\n other: 'meer as {{count}} jaar'\n },\n almostXYears: {\n one: 'byna 1 jaar',\n other: 'byna {{count}} jaar'\n }\n};\nvar formatDistance = function formatDistance(token, count, options) {\n var result;\n var tokenValue = formatDistanceLocale[token];\n if (typeof tokenValue === 'string') {\n result = tokenValue;\n } else if (count === 1) {\n result = tokenValue.one;\n } else {\n result = tokenValue.other.replace('{{count}}', String(count));\n }\n if (options !== null && options !== void 0 && options.addSuffix) {\n if (options.comparison && options.comparison > 0) {\n return 'oor ' + result;\n } else {\n return result + ' gelede';\n }\n }\n return result;\n};\nexport default formatDistance;", "import buildFormatLongFn from \"../../../_lib/buildFormatLongFn/index.js\";\nvar dateFormats = {\n full: 'EEEE, d MMMM yyyy',\n long: 'd MMMM yyyy',\n medium: 'd MMM yyyy',\n short: 'yyyy/MM/dd'\n};\nvar timeFormats = {\n full: 'HH:mm:ss zzzz',\n long: 'HH:mm:ss z',\n medium: 'HH:mm:ss',\n short: 'HH:mm'\n};\nvar dateTimeFormats = {\n full: \"{{date}} 'om' {{time}}\",\n long: \"{{date}} 'om' {{time}}\",\n medium: '{{date}}, {{time}}',\n short: '{{date}}, {{time}}'\n};\nvar formatLong = {\n date: buildFormatLongFn({\n formats: dateFormats,\n defaultWidth: 'full'\n }),\n time: buildFormatLongFn({\n formats: timeFormats,\n defaultWidth: 'full'\n }),\n dateTime: buildFormatLongFn({\n formats: dateTimeFormats,\n defaultWidth: 'full'\n })\n};\nexport default formatLong;", "var formatRelativeLocale = {\n lastWeek: \"'verlede' eeee 'om' p\",\n yesterday: \"'gister om' p\",\n today: \"'vandag om' p\",\n tomorrow: \"'môre om' p\",\n nextWeek: \"eeee 'om' p\",\n other: 'P'\n};\nvar formatRelative = function formatRelative(token, _date, _baseDate, _options) {\n return formatRelativeLocale[token];\n};\nexport default formatRelative;", "import buildLocalizeFn from \"../../../_lib/buildLocalizeFn/index.js\";\nvar eraValues = {\n narrow: ['vC', 'nC'],\n abbreviated: ['vC', 'nC'],\n wide: ['voor Christus', 'na Christus']\n};\nvar quarterValues = {\n narrow: ['1', '2', '3', '4'],\n abbreviated: ['K1', 'K2', 'K3', 'K4'],\n wide: ['1ste kwartaal', '2de kwartaal', '3de kwartaal', '4de kwartaal']\n};\nvar monthValues = {\n narrow: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'],\n abbreviated: ['Jan', 'Feb', 'Mrt', 'Apr', 'Mei', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Des'],\n wide: ['Januarie', 'Februarie', 'Maart', 'April', 'Mei', 'Junie', 'Julie', 'Augustus', 'September', 'Oktober', 'November', 'Desember']\n};\nvar dayValues = {\n narrow: ['S', 'M', 'D', 'W', 'D', 'V', 'S'],\n short: ['So', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Sa'],\n abbreviated: ['Son', 'Maa', 'Din', 'Woe', 'Don', 'Vry', 'Sat'],\n wide: ['Sondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrydag', 'Saterdag']\n};\nvar dayPeriodValues = {\n narrow: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n },\n abbreviated: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n },\n wide: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n }\n};\nvar formattingDayPeriodValues = {\n narrow: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'uur die middag',\n morning: 'uur die oggend',\n afternoon: 'uur die middag',\n evening: 'uur die aand',\n night:Line truncated
"mappings": "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 truncated
"names": ["formatDistance", "formatDistance_default", "formatRelative", "formatRelative_default", "ordinalNumber", "localize_default", "valueCallback", "match_default", "formatDistance_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuartLine truncated
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {
require_react_dom
} from "./chunk-TDH2IRYZ.js";
import "./chunk-QRULMDK5.js";
} from "./chunk-PJEEZAML.js";
import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map
+2 -2
View File
@@ -1,7 +1,7 @@
import {
require_react_dom
} from "./chunk-TDH2IRYZ.js";
import "./chunk-QRULMDK5.js";
} from "./chunk-PJEEZAML.js";
import "./chunk-DRWLMN53.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\r\n\r\nvar m = require('react-dom');\r\nif (process.env.NODE_ENV === 'production') {\r\n exports.createRoot = m.createRoot;\r\n exports.hydrateRoot = m.hydrateRoot;\r\n} else {\r\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n exports.createRoot = function(c, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.createRoot(c, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n exports.hydrateRoot = function(c, h, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.hydrateRoot(c, h, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n}\r\n"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {
GenIcon
} from "./chunk-7VTGDDTZ.js";
import "./chunk-QRULMDK5.js";
} from "./chunk-DQJKJRV5.js";
import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa/index.esm.js
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../react-icons/fa/index.esm.js"],
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\r\nimport { GenIcon } from '../lib';\r\nexport function Fa500Px (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\r\n};\r\nexport function FaAccessibleIcon (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\r\n};\r\nexport function FaAccusoft (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\r\n};\r\nexport function FaAcquisitionsIncorporated (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 384 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M357.45 468.2c-1.2-7.7-1.3-7.6-9.6-7.6-99.8.2-111.8-2.4-112.7-2.6-12.3-1.7-20.6-10.5-21-23.1-.1-1.6-.2-71.6-1-129.1-.1-4.7 1.6-6.4 5.9-7.5 12.5-3 24.9-6.1 37.3-9.7 4.3-1.3 6.8-.2 8.4 3.5 4.5 10.3 8.8 20.6 13.2 30.9 1.6 3.7.1 4.4-3.4 4.4-10-.2-20-.1-30.4-.1v27h116c-1.4-9.5-2.7-18.1-4-27.5-7 0-13.8.4-20.4-.1-22.6-1.6-18.3-4.4-84-158.6-8.8-20.1-27.9-62.1-36.5-89.2-4.4-14 5.5-25.4 18.9-26.6 18.6-1.7 37.5-1.6 56.2-2 20.6-.4 41.2-.4 61.8-.5 3.1 0 4-1.4 4.3-4.3 1.2-9.8 2.7-19.5 4-29.2.8-5.3 1.6-10.7 2.4-16.1L23.75 0c-3.6 0-5.3 1.1-4.6 5.3 2.2 13.2-.8.8 6.4 45.3 63.4 0 71.8.9 101.8.5 12.3-.2 37 3.5 37.7 22.1.4 11.4-1.1 11.3-32.6 87.4-53.8 129.8-50.7 120.3-67.3 161-1.7 4.1-3.6 5.2-7.6 5.2-8.5-.2-17-.3-25.4.1-1.9.1-5.2 1.8-5.5 3.2-1.5 8.1-2.2 16.3-3.2 24.9h114.3v-27.6c-6.9 0-33.5.4-35.3-2.9 5.3-12.3 10.4-24.4 15.7-36.7 16.3 4 31.9 7.8 47.6 11.7 3.4.9 4.6 3 4.6 6.8-.1 42.9.1 85.9.2 128.8 0 10.2-5.5 19.1-14.9 23.1-6.5 2.7-3.3 3.4-121.4 2.4-5.3 0-7.1 2-7.6 6.8-1.5 12.9-2.9 25.9-5 38.8-.8 5 1.3 5.7 5.3 5.7 183.2.6-30.7 0 337.1 0-2.5-15-4.4-29.4-6.6-43.7zm-174.9-205.7c-13.3-4.2-26.6-8.2-39.9-12.5a44.53 44.53 0 0 1-5.8-2.9c17.2-44.3 34.2-88.1 51.3-132.1 7.5 2.4 7.9-.8 9.4 0 9.3 22.5 18.1 60.1 27 82.8 6.6 16.7 13 33.5 19.7 50.9a35.78 35.78 0 0 1-3.9 2.1c-13.1 3.9-26.4 7.5-39.4 11.7a27.66 27.66 0 0 1-18.4 0z\"}}]})(props);\r\n};\r\nexport function FaAdn (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 1Line truncated
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\nimport { GenIcon } from '../lib';\nexport function Fa500Px (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\n};\nexport function FaAccessibleIcon (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\n};\nexport function FaAccusoft (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\n};\nexport function FaAcquisitionsIncorporated (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 384 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M357.45 468.2c-1.2-7.7-1.3-7.6-9.6-7.6-99.8.2-111.8-2.4-112.7-2.6-12.3-1.7-20.6-10.5-21-23.1-.1-1.6-.2-71.6-1-129.1-.1-4.7 1.6-6.4 5.9-7.5 12.5-3 24.9-6.1 37.3-9.7 4.3-1.3 6.8-.2 8.4 3.5 4.5 10.3 8.8 20.6 13.2 30.9 1.6 3.7.1 4.4-3.4 4.4-10-.2-20-.1-30.4-.1v27h116c-1.4-9.5-2.7-18.1-4-27.5-7 0-13.8.4-20.4-.1-22.6-1.6-18.3-4.4-84-158.6-8.8-20.1-27.9-62.1-36.5-89.2-4.4-14 5.5-25.4 18.9-26.6 18.6-1.7 37.5-1.6 56.2-2 20.6-.4 41.2-.4 61.8-.5 3.1 0 4-1.4 4.3-4.3 1.2-9.8 2.7-19.5 4-29.2.8-5.3 1.6-10.7 2.4-16.1L23.75 0c-3.6 0-5.3 1.1-4.6 5.3 2.2 13.2-.8.8 6.4 45.3 63.4 0 71.8.9 101.8.5 12.3-.2 37 3.5 37.7 22.1.4 11.4-1.1 11.3-32.6 87.4-53.8 129.8-50.7 120.3-67.3 161-1.7 4.1-3.6 5.2-7.6 5.2-8.5-.2-17-.3-25.4.1-1.9.1-5.2 1.8-5.5 3.2-1.5 8.1-2.2 16.3-3.2 24.9h114.3v-27.6c-6.9 0-33.5.4-35.3-2.9 5.3-12.3 10.4-24.4 15.7-36.7 16.3 4 31.9 7.8 47.6 11.7 3.4.9 4.6 3 4.6 6.8-.1 42.9.1 85.9.2 128.8 0 10.2-5.5 19.1-14.9 23.1-6.5 2.7-3.3 3.4-121.4 2.4-5.3 0-7.1 2-7.6 6.8-1.5 12.9-2.9 25.9-5 38.8-.8 5 1.3 5.7 5.3 5.7 183.2.6-30.7 0 337.1 0-2.5-15-4.4-29.4-6.6-43.7zm-174.9-205.7c-13.3-4.2-26.6-8.2-39.9-12.5a44.53 44.53 0 0 1-5.8-2.9c17.2-44.3 34.2-88.1 51.3-132.1 7.5 2.4 7.9-.8 9.4 0 9.3 22.5 18.1 60.1 27 82.8 6.6 16.7 13 33.5 19.7 50.9a35.78 35.78 0 0 1-3.9 2.1c-13.1 3.9-26.4 7.5-39.4 11.7a27.66 27.66 0 0 1-18.4 0z\"}}]})(props);\n};\nexport function FaAdn (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9Line truncated
"mappings": ";;;;;;;AAEO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,gpCAA+oC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5vC;AACO,SAAS,iBAAkB,OAAO;AACvC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,0rBAAyrB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACtyB;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uwBAAswB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACn3B;AACO,SAAS,2BAA4B,OAAO;AACjD,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,kyCAAiyC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC94C;AACO,SAAS,MAAO,OAAO;AAC5B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,8LAA6L,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC1S;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6sCAA4sC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACzzC;AACO,SAAS,iBAAkB,OAAO;AACvC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uUAAsU,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACnb;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,owBAAmwB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACh3B;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,8zBAA6zB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC16B;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,4pBAA2pB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACxwB;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6zGAA4zG,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACz6G;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,moBAAkoB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC/uB;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ghBAA+gB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5nB;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,gTAA+S,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5Z;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yxCAAwxC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACr4C;AACO,SAAS,gBAAiB,OAAO;AACtC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,k1DAAi1D,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC97D;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,kKAAiK,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC9Q;AACO,SAAS,cAAe,OAAO;AACpC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yoBAAwoB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACrvB;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6wBAA4wB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACz3B;AACO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,onDAAmnD,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChuD;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ylCAAwlC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACrsC;AACO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,sbAAqb,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACliB;AACO,SAAS,aAAc,OAAO;AACnC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,oMAAmM,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChT;AACO,SAAS,aAAc,OAAO;AACnC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,+qBAA8qB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC3xB;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,iSAAgS,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC7Y;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ifAAgf,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC7lB;AACO,SAAS,eAAgB,OAAO;AACrC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,0MAAyM,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACtT;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,8eAA6e,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC1lB;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ojEAAmjE,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChqE;AACO,SAAS,MAAO,OAAO;AAC5B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yrEAAwrE,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACryE;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uGAAsG,EAAC,CAAC,EALine truncated
"names": []
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {
GenIcon
} from "./chunk-7VTGDDTZ.js";
import "./chunk-QRULMDK5.js";
} from "./chunk-DQJKJRV5.js";
import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa6/index.esm.js
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../react-icons/fa6/index.esm.js"],
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\r\nimport { GenIcon } from '../lib';\r\nexport function Fa42Group (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M320 96V416C341.011 416 361.818 411.861 381.23 403.821C400.641 395.78 418.28 383.995 433.138 369.138C447.995 354.28 459.78 336.641 467.821 317.23C475.861 297.818 480 277.011 480 256C480 234.989 475.861 214.182 467.821 194.771C459.78 175.359 447.995 157.72 433.138 142.863C418.28 128.005 400.641 116.22 381.23 108.179C361.818 100.139 341.011 96 320 96ZM0 256L160.002 416L320.003 256L160.002 96L0 256ZM480 256C480 277.011 484.138 297.818 492.179 317.23C500.219 336.643 512.005 354.28 526.862 369.138C541.72 383.995 559.357 395.781 578.77 403.821C598.182 411.862 618.989 416 640 416V96C597.565 96 556.869 112.858 526.862 142.863C496.857 172.869 480 213.565 480 256Z\"}}]})(props);\r\n};\r\nexport function Fa500Px (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\r\n};\r\nexport function FaAccessibleIcon (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\r\n};\r\nexport function FaAccusoft (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\r\n};\r\nexport function FaAdn (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9 0 256 111.1 8 248 8s248 111.1 248 248zm-99.8 82.7L248 115.5 99.8 338.7h30.4l33.6-51.7h168.6l33.6 51.7h30.2z\"}}]})(props);\r\n};\r\nexport function FaAdversal (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 512 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M482.1 32H28.7C5.8 32 0 37.9 0 60.9v390.2C0 474.4 5.8 480 28.7 480h453.4c24.4 0 29.9-5.2 29.9-29.7V62.2c0-24.6-5.4-30.2-29.9-30.2zM178.4 220.3c-27.5-20.2-72.1-8.7-84.2 23.4-4.3 11.1-9.3 9.5-17.5 8.3-9.7-1.5-17.2-3.2-22.5-5.5-28.8-11.4 8.6-55.3 24.9-64.3 41.1-21.4 83.4-22.2 125.3-4.8 40.9 16.8 34.5 59.2 34.5 128.5 2.7 25.8-4.3 58.3 9.3 88.8 1Line truncated
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\nimport { GenIcon } from '../lib';\nexport function Fa42Group (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M320 96V416C341.011 416 361.818 411.861 381.23 403.821C400.641 395.78 418.28 383.995 433.138 369.138C447.995 354.28 459.78 336.641 467.821 317.23C475.861 297.818 480 277.011 480 256C480 234.989 475.861 214.182 467.821 194.771C459.78 175.359 447.995 157.72 433.138 142.863C418.28 128.005 400.641 116.22 381.23 108.179C361.818 100.139 341.011 96 320 96ZM0 256L160.002 416L320.003 256L160.002 96L0 256ZM480 256C480 277.011 484.138 297.818 492.179 317.23C500.219 336.643 512.005 354.28 526.862 369.138C541.72 383.995 559.357 395.781 578.77 403.821C598.182 411.862 618.989 416 640 416V96C597.565 96 556.869 112.858 526.862 142.863C496.857 172.869 480 213.565 480 256Z\"}}]})(props);\n};\nexport function Fa500Px (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\n};\nexport function FaAccessibleIcon (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\n};\nexport function FaAccusoft (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\n};\nexport function FaAdn (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9 0 256 111.1 8 248 8s248 111.1 248 248zm-99.8 82.7L248 115.5 99.8 338.7h30.4l33.6-51.7h168.6l33.6 51.7h30.2z\"}}]})(props);\n};\nexport function FaAdversal (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 512 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M482.1 32H28.7C5.8 32 0 37.9 0 60.9v390.2C0 474.4 5.8 480 28.7 480h453.4c24.4 0 29.9-5.2 29.9-29.7V62.2c0-24.6-5.4-30.2-29.9-30.2zM178.4 220.3c-27.5-20.2-72.1-8.7-84.2 23.4-4.3 11.1-9.3 9.5-17.5 8.3-9.7-1.5-17.2-3.2-22.5-5.5-28.8-11.4 8.6-55.3 24.9-64.3 41.1-21.4 83.4-22.2 125.3-4.8 40.9 16.8 34.5 59.2 34.5 128.5 2.7 25.8-4.3 58.3 9.3 88.8 1.9 4.4.4 7.9-2.7 10.7-8.4 6.7-39.3 2Line truncated
"mappings": ";;;;;;;AAEO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ypBAAwpB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACrwB;AACO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,gpCAA+oC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5vC;AACO,SAAS,iBAAkB,OAAO;AACvC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,0rBAAyrB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACtyB;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uwBAAswB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACn3B;AACO,SAAS,MAAO,OAAO;AAC5B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,8LAA6L,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC1S;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6sCAA4sC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACzzC;AACO,SAAS,iBAAkB,OAAO;AACvC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uUAAsU,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACnb;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,owBAAmwB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACh3B;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6pBAA4pB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACzwB;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,4pBAA2pB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACxwB;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6zGAA4zG,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACz6G;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,moBAAkoB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC/uB;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ghBAA+gB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5nB;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,gTAA+S,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC5Z;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yxCAAwxC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACr4C;AACO,SAAS,gBAAiB,OAAO;AACtC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,k1DAAi1D,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC97D;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,kKAAiK,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC9Q;AACO,SAAS,cAAe,OAAO;AACpC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yoBAAwoB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACrvB;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,6wBAA4wB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACz3B;AACO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,onDAAmnD,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChuD;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ylCAAwlC,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACrsC;AACO,SAAS,QAAS,OAAO;AAC9B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,sbAAqb,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACliB;AACO,SAAS,aAAc,OAAO;AACnC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,oMAAmM,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChT;AACO,SAAS,aAAc,OAAO;AACnC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,+qBAA8qB,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC3xB;AACO,SAAS,YAAa,OAAO;AAClC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,iSAAgS,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC7Y;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ifAAgf,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC7lB;AACO,SAAS,eAAgB,OAAO;AACrC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,0MAAyM,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACtT;AACO,SAAS,UAAW,OAAO;AAChC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,8eAA6e,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAC1lB;AACO,SAAS,SAAU,OAAO;AAC/B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,ojEAAmjE,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AAChqE;AACO,SAAS,MAAO,OAAO;AAC5B,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,yrEAAwrE,EAAC,CAAC,EAAC,CAAC,EAAE,KAAK;AACryE;AACO,SAAS,WAAY,OAAO;AACjC,SAAO,QAAQ,EAAC,OAAM,OAAM,QAAO,EAAC,WAAU,cAAa,GAAE,SAAQ,CAAC,EAAC,OAAM,QAAO,QAAO,EAAC,KAAI,uGAAsG,EAAC,CAAC,EAACLine truncated
"names": []
}
+2 -2
View File
@@ -1,9 +1,9 @@
import {
require_react_dom
} from "./chunk-TDH2IRYZ.js";
} from "./chunk-PJEEZAML.js";
import {
require_react
} from "./chunk-QRULMDK5.js";
} from "./chunk-DRWLMN53.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-QRULMDK5.js";
} from "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js";
export default require_react();
//# sourceMappingURL=react.js.map
+1 -1
View File
@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-QRULMDK5.js";
} from "./chunk-DRWLMN53.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../react/cjs/react-jsx-dev-runtime.development.js", "../../react/jsx-dev-runtime.js"],
"sourcesContent": ["/**\r\n * @license React\r\n * react-jsx-dev-runtime.development.js\r\n *\r\n * Copyright (c) Facebook, Inc. and its affiliates.\r\n *\r\n * This source code is licensed under the MIT license found in the\r\n * LICENSE file in the root directory of this source tree.\r\n */\r\n\r\n'use strict';\r\n\r\nif (process.env.NODE_ENV !== \"production\") {\r\n (function() {\r\n'use strict';\r\n\r\nvar React = require('react');\r\n\r\n// ATTENTION\r\n// When adding new symbols to this file,\r\n// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols'\r\n// The Symbol used to tag the ReactElement-like types.\r\nvar REACT_ELEMENT_TYPE = Symbol.for('react.element');\r\nvar REACT_PORTAL_TYPE = Symbol.for('react.portal');\r\nvar REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');\r\nvar REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');\r\nvar REACT_PROFILER_TYPE = Symbol.for('react.profiler');\r\nvar REACT_PROVIDER_TYPE = Symbol.for('react.provider');\r\nvar REACT_CONTEXT_TYPE = Symbol.for('react.context');\r\nvar REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');\r\nvar REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');\r\nvar REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');\r\nvar REACT_MEMO_TYPE = Symbol.for('react.memo');\r\nvar REACT_LAZY_TYPE = Symbol.for('react.lazy');\r\nvar REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');\r\nvar MAYBE_ITERATOR_SYMBOL = Symbol.iterator;\r\nvar FAUX_ITERATOR_SYMBOL = '@@iterator';\r\nfunction getIteratorFn(maybeIterable) {\r\n if (maybeIterable === null || typeof maybeIterable !== 'object') {\r\n return null;\r\n }\r\n\r\n var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];\r\n\r\n if (typeof maybeIterator === 'function') {\r\n return maybeIterator;\r\n }\r\n\r\n return null;\r\n}\r\n\r\nvar ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n\r\nfunction error(format) {\r\n {\r\n {\r\n for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {\r\n args[_key2 - 1] = arguments[_key2];\r\n }\r\n\r\n printWarning('error', format, args);\r\n }\r\n }\r\n}\r\n\r\nfunction printWarning(level, format, args) {\r\n // When changing this logic, you might want to also\r\n // update consoleWithStackDev.www.js as well.\r\n {\r\n var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;\r\n var stack = ReactDebugCurrentFrame.getStackAddendum();\r\n\r\n if (stack !== '') {\r\n format += '%s';\r\n args = args.concat([stack]);\r\n } // eslint-disable-next-line react-internal/safe-string-coercion\r\n\r\n\r\n var argsWithFormat = args.map(function (item) {\r\n return String(item);\r\n }); // Careful: RN currently depends on this prefix\r\n\r\n argsWithFormat.unshift('Warning: ' + format); // We intentionally don't use spread (or .apply) directly because it\r\n // breaks IE9: https://github.com/facebook/react/issues/13610\r\n // eslint-disable-next-line react-internal/no-production-logging\r\n\r\n Function.prototype.apply.call(console[level], console, argsWithFormat);\r\n }\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n\r\nvar enableScopeAPI = false; // Experimental Create Event Handle API.\r\nvar enableCacheElement = false;\r\nvar enableTransitionTracing = false; // No known bugs, but needs performance testing\r\n\r\nvar enableLegacyHidden = false; // Enables unstable_avoidThisFallback feature in Fiber\r\n// stuff. Intended to enable React core members to more easily debug scheduling\r\n// issues in DEV builds.\r\n\r\nvar enableDebugTracing = false; // Track which Fiber(s) schedule render work.\r\n\r\nvar REACT_MODULE_REFERENCE;\r\n\r\n{\r\n REACT_MODULE_REFERENCE = Symbol.for('react.module.reference');\r\n}\r\n\r\nfunction isValidElementType(type) {\r\n if (typeof type === 'string' || typeof type === 'function') {\r\n return true;\r\n } // Note: typeof might be other than 'symbol' or 'number' (e.g. if it's a polyfill).\r\n\r\n\r\n if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing ) {\r\n return true;\r\n }\r\n\r\n if (typeof type === 'object' && type !== null) {\r\n if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object\r\n // types supported by any Flight configuration anywhere since\r\n // we don't know which Flight buLine truncated
"sourcesContent": ["/**\n * @license React\n * react-jsx-dev-runtime.development.js\n *\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n */\n\n'use strict';\n\nif (process.env.NODE_ENV !== \"production\") {\n (function() {\n'use strict';\n\nvar React = require('react');\n\n// ATTENTION\n// When adding new symbols to this file,\n// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols'\n// The Symbol used to tag the ReactElement-like types.\nvar REACT_ELEMENT_TYPE = Symbol.for('react.element');\nvar REACT_PORTAL_TYPE = Symbol.for('react.portal');\nvar REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');\nvar REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');\nvar REACT_PROFILER_TYPE = Symbol.for('react.profiler');\nvar REACT_PROVIDER_TYPE = Symbol.for('react.provider');\nvar REACT_CONTEXT_TYPE = Symbol.for('react.context');\nvar REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');\nvar REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');\nvar REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');\nvar REACT_MEMO_TYPE = Symbol.for('react.memo');\nvar REACT_LAZY_TYPE = Symbol.for('react.lazy');\nvar REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');\nvar MAYBE_ITERATOR_SYMBOL = Symbol.iterator;\nvar FAUX_ITERATOR_SYMBOL = '@@iterator';\nfunction getIteratorFn(maybeIterable) {\n if (maybeIterable === null || typeof maybeIterable !== 'object') {\n return null;\n }\n\n var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];\n\n if (typeof maybeIterator === 'function') {\n return maybeIterator;\n }\n\n return null;\n}\n\nvar ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n\nfunction error(format) {\n {\n {\n for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {\n args[_key2 - 1] = arguments[_key2];\n }\n\n printWarning('error', format, args);\n }\n }\n}\n\nfunction printWarning(level, format, args) {\n // When changing this logic, you might want to also\n // update consoleWithStackDev.www.js as well.\n {\n var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;\n var stack = ReactDebugCurrentFrame.getStackAddendum();\n\n if (stack !== '') {\n format += '%s';\n args = args.concat([stack]);\n } // eslint-disable-next-line react-internal/safe-string-coercion\n\n\n var argsWithFormat = args.map(function (item) {\n return String(item);\n }); // Careful: RN currently depends on this prefix\n\n argsWithFormat.unshift('Warning: ' + format); // We intentionally don't use spread (or .apply) directly because it\n // breaks IE9: https://github.com/facebook/react/issues/13610\n // eslint-disable-next-line react-internal/no-production-logging\n\n Function.prototype.apply.call(console[level], console, argsWithFormat);\n }\n}\n\n// -----------------------------------------------------------------------------\n\nvar enableScopeAPI = false; // Experimental Create Event Handle API.\nvar enableCacheElement = false;\nvar enableTransitionTracing = false; // No known bugs, but needs performance testing\n\nvar enableLegacyHidden = false; // Enables unstable_avoidThisFallback feature in Fiber\n// stuff. Intended to enable React core members to more easily debug scheduling\n// issues in DEV builds.\n\nvar enableDebugTracing = false; // Track which Fiber(s) schedule render work.\n\nvar REACT_MODULE_REFERENCE;\n\n{\n REACT_MODULE_REFERENCE = Symbol.for('react.module.reference');\n}\n\nfunction isValidElementType(type) {\n if (typeof type === 'string' || typeof type === 'function') {\n return true;\n } // Note: typeof might be other than 'symbol' or 'number' (e.g. if it's a polyfill).\n\n\n if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing ) {\n return true;\n }\n\n if (typeof type === 'object' && type !== null) {\n if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object\n // types supported by any Flight configuration anywhere since\n // we don't know which Flight build this will end up being used\n // with.\n type.$$typeof === REACT_MODULE_REFERENCE || type.getModuleId !== undefined) {\n return true;\n }\n }\n\n return false;\n}\n\nfunction getWrappedName(outerType, innerType, wrapperNamLine truncated
"mappings": "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 truncated
"names": ["ReactDebugCurrentFrame"]
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {
require_jsx_runtime
} from "./chunk-NMLHVZ76.js";
import "./chunk-QRULMDK5.js";
} from "./chunk-6PXSGDAH.js";
import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js";
export default require_jsx_runtime();
//# sourceMappingURL=react_jsx-runtime.js.map
+7 -153
View File
@@ -10,20 +10,14 @@
"dependencies": {
"@tabler/icons-react": "^3.36.0",
"appwrite": "^13.0.0",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"motion": "^12.23.26",
"postprocessing": "^6.38.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0"
"uplot": "^1.6.32"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
@@ -56,7 +50,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1086,32 +1079,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1174,7 +1141,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1221,15 +1187,6 @@
}
]
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1255,12 +1212,6 @@
"node-fetch": "2.6.7"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@@ -1424,33 +1375,6 @@
"node": ">= 0.12"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1648,47 +1572,6 @@
"node": ">= 0.6"
}
},
"node_modules/motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.26",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1772,20 +1655,10 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postprocessing": {
"version": "6.38.2",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.2.tgz",
"integrity": "sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==",
"license": "Zlib",
"peerDependencies": {
"three": ">= 0.157.0 < 0.183.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1797,7 +1670,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1939,34 +1811,11 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT",
"peer": true
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1997,12 +1846,17 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+3 -8
View File
@@ -5,26 +5,21 @@
"dependencies": {
"@tabler/icons-react": "^3.36.0",
"appwrite": "^13.0.0",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"motion": "^12.23.26",
"postprocessing": "^6.38.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0"
"uplot": "^1.6.32"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test tests/*.test.mjs tests/unit/*.test.mjs"
}
}
+180
View File
@@ -0,0 +1,180 @@
{
"versions": [
{
"documentKey": "agb",
"version": "v1",
"validFrom": "2026-02-02",
"status": "superseded",
"changeNote": "Erste Fassung der AGB auf webklar.com",
"source": "webklar.com@217bbdc:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v2",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "Modul C Hosting ergänzt (Hosting-Abo, Wartungs-Abo mit beliebig vielen Postfächern)",
"source": "webklar.com@dc27e50:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v3",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "An die Vertragsbedingungen angeglichen (Wechsel, Zusatzspeicher, Sicherheit), Verweise auf die Abo-Bedingungen",
"source": "webklar.com@2c1a4c1:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v4",
"validFrom": "2026-09-28",
"status": "superseded",
"changeNote": "Haftungssatz in Modul C entfällt; Kündigung nach Verlängerung für Verbraucher",
"source": "webklar.com@2bffca4:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v5",
"validFrom": "2026-09-28",
"status": "current",
"changeNote": "Verbraucher-Kündigung als Ausnahme formuliert",
"source": "webklar.com@044f788:src/pages/AGB.tsx"
},
{
"documentKey": "agb",
"version": "v6",
"validFrom": "2026-09-28",
"status": "draft",
"changeNote": "§ 11 Haftung neu nach dem Muster der Abo-Bedingungen (noch nicht live)",
"source": "webklar.com@f40e889:src/pages/AGB.tsx"
},
{
"documentKey": "abo-bedingungen",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Erste Fassung: Wartungs-Abo 50 €/Monat über Stripe mit Stundenkonto",
"source": "kundenbereich@65429aa:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Vertragsunterlagen Version 2 nach Rechtsrunde, Postfächer und Domains bei Hetzner",
"source": "kundenbereich@4ab154b:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v3",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Version 3: Mindestlaufzeit 12 Monate; Kontakt support@webklar.com",
"source": "kundenbereich@dbabbf1:public/abo-bedingungen.html"
},
{
"documentKey": "abo-bedingungen",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "Version 4: Hosting-Abo und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel, AVV; § 13 Abs. 3 mit Ausnahme für Absatz 1; Hosting-Preis 20 €",
"source": "kundenbereich@e4efdac:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Erste AVV als Anlage der Vertragsbedingungen Version 2 (in Version 1 nur „auf Anfrage“)",
"source": "kundenbereich@4ab154b:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v3",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "AVV als Anlage der Vertragsbedingungen Version 3",
"source": "kundenbereich@dbabbf1:public/abo-bedingungen.html"
},
{
"documentKey": "avv",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "AVV als Anlage der Vertragsbedingungen Version 4 (Anlagen 1 und 3 überarbeitet)",
"source": "kundenbereich@e4efdac:public/abo-bedingungen.html"
},
{
"documentKey": "datenschutz",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Stand 25.09.2026: Abo, Übernahmegutschrift",
"source": "kundenbereich@48b13f4:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v2",
"validFrom": "2026-09-26",
"status": "superseded",
"changeNote": "Stand 26.09.2026: Chat mit Bildern, Dateien und Sprachnachrichten",
"source": "kundenbereich@35d98da:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v3",
"validFrom": "2026-09-27",
"status": "superseded",
"changeNote": "Stand 27.09.2026: E-Mail bei Hetzner statt Porkbun, UptimeRobot, Bestellungen im Portal",
"source": "kundenbereich@7e86928:public/datenschutz.html"
},
{
"documentKey": "datenschutz",
"version": "v4",
"validFrom": "2026-09-30",
"status": "current",
"changeNote": "Stand 30.09.2026: Livegang mit Vertragsbedingungen Version 4",
"source": "kundenbereich@78b4d7b:public/datenschutz.html"
},
{
"documentKey": "impressum",
"version": "v1",
"validFrom": "2026-02-02",
"status": "superseded",
"changeNote": "Erste Fassung",
"source": "webklar.com@6af24d2:src/pages/Impressum.tsx"
},
{
"documentKey": "impressum",
"version": "v2",
"validFrom": "2026-09-25",
"status": "current",
"changeNote": "Neu nach DDG mit Gesellschaftern; Kontakt support@webklar.com",
"source": "webklar.com@f74ddfb:src/pages/Impressum.tsx"
},
{
"documentKey": "widerruf",
"version": "v1",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Widerrufsformular im Kundenportal",
"source": "kundenbereich@65429aa:public/widerrufen.html"
},
{
"documentKey": "widerruf",
"version": "v2",
"validFrom": "2026-09-25",
"status": "superseded",
"changeNote": "Überarbeitet nach Rechtsrunde",
"source": "kundenbereich@4ab154b:public/widerrufen.html"
},
{
"documentKey": "widerruf",
"version": "v3",
"validFrom": "2026-09-29",
"status": "current",
"changeNote": "Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)",
"source": "kundenbereich@7ffe95f:public/widerrufen.html"
}
]
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* WOMS Rechtliches – Erstbefuellung (idempotent ueber das Feld `source`).
* Liest scripts/legal-seed.json, erzeugt je Version eine Archivkopie als HTML
* (Portal-HTML direkt aus Git, Website-Seiten per React zu HTML gerendert),
* laedt sie in den Bucket legal-files und legt die Versionen an. Dazu die
* Anbieter-Vertraege ohne Fristen und Preise.
*
* Usage:
* node scripts/seed-legal.mjs --dry-run [--out DIR] (nur rendern, nichts hochladen)
* APPWRITE_API_KEY=... node scripts/seed-legal.mjs
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const APPS = "/home/webklar/apps";
const REPOS = {
"webklar.com": process.env.WEBSITE_REPO || `${APPS}/webklar.com/build`,
kundenbereich: process.env.PORTAL_REPO || `${APPS}/kundenbereich.webklar.com/build`,
};
const NODE_MODULES = process.env.WOMS_NODE_MODULES || `${APPS}/ticket.webklar.com/build/node_modules`;
const dryRun = process.argv.includes("--dry-run");
const outIdx = process.argv.indexOf("--out");
const outDir = outIdx > 0 ? process.argv[outIdx + 1] : null;
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
export const PROVIDERS = [
{ provider: "Hetzner", purpose: "Server (Hosting aller Websites und Systeme)" },
{ provider: "Hetzner", purpose: "E-Mail-Postfächer" },
{ provider: "Porkbun", purpose: "Domains" },
{ provider: "Stripe", purpose: "Zahlungen der Abos" },
];
const require = createRequire(path.join(NODE_MODULES, "noop.js"));
function gitShow(repo, hash, file) {
return execFileSync("git", ["-C", REPOS[repo], "show", `${hash}:${file}`], { maxBuffer: 20 * 1024 * 1024 });
}
// Alles ausser React wird zu einem Platzhalter: Komponenten geben nur ihre Kinder aus,
// Link wird zu <a>, Icons verschwinden. Die Exportnamen kommen aus den Imports der Seite.
function stubFor(source, specifier) {
const names = new Set();
let hasDefault = false;
const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*["']([^"']+)["']/g;
for (const m of source.matchAll(re)) {
if (m[3] !== specifier) continue;
if (m[1]) hasDefault = true;
for (const n of (m[2] || "").split(",")) {
const name = n.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, "");
if (name) names.add(name);
}
}
const lines = [
"import React from 'react';",
"const Pass = (p) => (p && p.children !== undefined ? React.createElement(React.Fragment, null, p.children) : null);",
];
for (const n of names) {
lines.push(n === "Link" || n === "NavLink"
? `export const ${n} = (p) => React.createElement('a', { href: p.to || p.href }, p.children);`
: /^[A-Z]/.test(n) ? `export const ${n} = Pass;` : `export const ${n} = () => undefined;`);
}
if (hasDefault) lines.push("export default Pass;");
return lines.join("\n");
}
async function renderTsx(source) {
const esbuild = require("esbuild");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "legal-"));
const entry = path.join(tmp, "page.tsx");
fs.writeFileSync(entry, source);
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "cjs",
platform: "node",
jsx: "automatic",
nodePaths: [NODE_MODULES],
external: ["react", "react-dom", "react/jsx-runtime"],
logLevel: "silent",
plugins: [{
name: "stub",
setup(b) {
b.onResolve({ filter: /.*/ }, (a) =>
a.kind === "entry-point" || /^react(\/jsx-runtime)?$/.test(a.path) ? undefined : { path: a.path, namespace: "stub" });
b.onLoad({ filter: /.*/, namespace: "stub" }, (a) => ({ contents: stubFor(source, a.path), loader: "js", resolveDir: NODE_MODULES }));
},
}],
});
fs.rmSync(tmp, { recursive: true, force: true });
const mod = { exports: {} };
new Function("module", "exports", "require", result.outputFiles[0].text)(mod, mod.exports, require);
const Page = mod.exports.default;
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
const html = renderToStaticMarkup(React.createElement(Page));
const main = /<main[\s\S]*<\/main>/.exec(html);
return main ? main[0] : html;
}
function wrap(title, entry, body) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} ${entry.version} – Archivkopie</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:820px;margin:0 auto;padding:24px;color:#111}
.archiv{background:#f3f4f6;border:1px solid #d1d5db;padding:8px 12px;border-radius:6px;font-size:13px;margin-bottom:24px}
svg{display:none}h1{font-size:28px}</style></head>
<body><div class="archiv">Archivkopie aus WOMS · ${title} ${entry.version} · gültig ab ${entry.validFrom} · Quelle ${entry.source}</div>
${body}
</body></html>`;
}
export async function buildFile(entry, title) {
const [repo, rest] = entry.source.split("@");
const [hash, file] = rest.split(":");
const raw = gitShow(repo, hash, file);
const name = `${entry.documentKey}-${entry.version}-${hash}.html`;
if (file.endsWith(".html")) return { name, content: raw };
const body = await renderTsx(raw.toString("utf8"));
return { name, content: Buffer.from(wrap(title, entry, body), "utf8") };
}
async function run() {
const { versions } = JSON.parse(fs.readFileSync(path.join(HERE, "legal-seed.json"), "utf8"));
const { DOCUMENTS } = await import("./setup-legal.mjs");
const titles = Object.fromEntries(DOCUMENTS.map((d) => [d.key, d.title]));
if (dryRun) {
if (outDir) fs.mkdirSync(outDir, { recursive: true });
for (const v of versions) {
const f = await buildFile(v, titles[v.documentKey]);
if (outDir) fs.writeFileSync(path.join(outDir, f.name), f.content);
console.log(`${v.documentKey} ${v.version} ${v.status} ab ${v.validFrom}: ${f.name} (${f.content.length} Bytes)`);
}
for (const p of PROVIDERS) console.log(`Anbieter: ${p.provider} – ${p.purpose}`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases, Storage, ID, Query } = await import("node-appwrite");
const { InputFile } = await import("node-appwrite/file");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const storage = new Storage(client);
let created = 0;
for (const v of versions) {
const found = await db.listDocuments(databaseId, "legalVersions", [Query.equal("source", v.source), Query.equal("documentKey", v.documentKey), Query.limit(1)]);
if (found.total > 0) {
console.log("vorhanden:", v.documentKey, v.version);
continue;
}
const f = await buildFile(v, titles[v.documentKey]);
const file = await storage.createFile("legal-files", ID.unique(), InputFile.fromBuffer(f.content, f.name));
const doc = { ...v, validTo: null, fileIds: [file.$id] };
await db.createDocument(databaseId, "legalVersions", ID.unique(), doc);
created++;
console.log("angelegt:", v.documentKey, v.version);
}
// validTo der abgeloesten Versionen: Tag vor der naechsten nicht-Entwurf-Version, nie vor dem eigenen Beginn
const all = (await db.listDocuments(databaseId, "legalVersions", [Query.limit(5000)])).documents;
for (const v of all.filter((x) => x.status === "superseded" && !x.validTo)) {
const next = all
.filter((x) => x.documentKey === v.documentKey && x.status !== "draft" && x.$id !== v.$id &&
(x.validFrom > v.validFrom || (x.validFrom === v.validFrom && x.version > v.version)))
.sort((a, b) => (a.validFrom + a.version).localeCompare(b.validFrom + b.version))[0];
if (!next) continue;
const d = new Date(`${next.validFrom}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
const before = d.toISOString().slice(0, 10);
await db.updateDocument(databaseId, "legalVersions", v.$id, { validTo: before < v.validFrom ? v.validFrom : before });
}
const existing = (await db.listDocuments(databaseId, "providerContracts", [Query.limit(5000)])).documents;
for (const p of PROVIDERS) {
if (existing.some((c) => c.provider === p.provider && c.purpose === p.purpose)) continue;
await db.createDocument(databaseId, "providerContracts", ID.unique(), { ...p, fileIds: [] });
console.log("Anbieter angelegt:", p.provider, p.purpose);
}
console.log(`Fertig: ${created} Versionen angelegt.`);
}
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* WOMS Rechtliches – Appwrite-Setup (idempotent).
* Legt die Sammlungen legalDocuments, legalVersions, providerContracts, den Bucket
* legal-files und die festen Rechtstext-Karten an. Versionen und Dateien bekommen
* bewusst keine Loeschrechte: das Archiv soll vollstaendig bleiben.
*
* Usage:
* node scripts/setup-legal.mjs --dry-run
* APPWRITE_API_KEY=... node scripts/setup-legal.mjs
*/
import { pathToFileURL } from "node:url";
import { staffPermissions } from "../src/lib/staffAccess.js";
const dryRun = process.argv.includes("--dry-run");
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
// Nur Mitarbeiter (Labels), nie "users": das waeren auch alle Kunden-Konten des Portals
const READ_WRITE = staffPermissions("read", "create", "update");
const CRUD = staffPermissions("read", "create", "update", "delete");
const date = (key) => ({ key, type: "string", size: 10 });
export const COLLECTIONS = [
{
id: "legalDocuments",
name: "Rechtstexte",
perms: READ_WRITE,
attrs: [
{ key: "key", type: "string", size: 64, required: true },
{ key: "title", type: "string", size: 200, required: true },
{ key: "liveUrl", type: "string", size: 500 },
{ key: "sort", type: "integer", min: 0, max: 1000 },
],
indexes: [{ key: "idx_sort", type: "key", attributes: ["sort"] }],
},
{
id: "legalVersions",
name: "Rechtstext-Versionen",
perms: READ_WRITE,
attrs: [
{ key: "documentKey", type: "string", size: 64, required: true },
{ key: "version", type: "string", size: 32, required: true },
date("validFrom"),
date("validTo"),
{ key: "status", type: "enum", elements: ["draft", "current", "superseded"], required: true },
{ key: "changeNote", type: "string", size: 5000 },
{ key: "fileIds", type: "string[]", size: 64 },
{ key: "source", type: "string", size: 300 },
],
indexes: [
{ key: "idx_documentKey", type: "key", attributes: ["documentKey"] },
{ key: "idx_source", type: "key", attributes: ["source"] },
],
},
{
id: "providerContracts",
name: "Anbieter-Verträge",
perms: CRUD,
attrs: [
{ key: "provider", type: "string", size: 200, required: true },
{ key: "purpose", type: "string", size: 300 },
{ key: "cost", type: "float" },
{ key: "costInterval", type: "enum", elements: ["month", "year"] },
date("startDate"),
{ key: "termMonths", type: "integer", min: 0, max: 1200 },
{ key: "renewalMonths", type: "integer", min: 0, max: 1200 },
{ key: "noticeValue", type: "integer", min: 0, max: 3650 },
{ key: "noticeUnit", type: "enum", elements: ["days", "months"] },
date("cancelledAt"),
date("endsAt"),
{ key: "note", type: "string", size: 5000 },
{ key: "fileIds", type: "string[]", size: 64 },
],
indexes: [{ key: "idx_provider", type: "key", attributes: ["provider"] }],
},
];
export const BUCKET = {
id: "legal-files",
name: "Rechtliches Dateien",
perms: staffPermissions("read", "create"),
maxSize: 20 * 1024 * 1024,
extensions: ["pdf", "html", "htm", "png", "jpg", "jpeg", "webp", "doc", "docx"],
};
export const DOCUMENTS = [
{ key: "agb", title: "AGB", liveUrl: "https://webklar.com/agb" },
{ key: "abo-bedingungen", title: "Abo-Bedingungen", liveUrl: "https://project.webklar.com/abo-bedingungen.html" },
{ key: "avv", title: "AVV (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/abo-bedingungen.html" },
{ key: "datenschutz", title: "Datenschutzerklärung (Website und Portal)", liveUrl: "https://project.webklar.com/datenschutz.html" },
{ key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" },
{ key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" },
// Terminplaner (TP2): live ist die Alias-Seite ohne Fassungsdatum (Portal, Aufgabe F1); die Versionen (PDF/HTML der Fassung) laedt der Inhaber hoch
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Wartet, bis Appwrite alle Attribute einer Sammlung fertig angelegt hat. */
export async function waitForAttributes(db, collectionId, { intervalMs = 1000, timeoutMs = 120000 } = {}) {
const until = Date.now() + timeoutMs;
for (;;) {
const { attributes } = await db.listAttributes(databaseId, collectionId);
const failed = attributes.filter((a) => a.status === "failed" || a.status === "stuck");
if (failed.length) {
throw new Error(`Attribut fehlgeschlagen in ${collectionId}: ${failed.map((a) => `${a.key} (${a.error || a.status})`).join(", ")}`);
}
if (attributes.every((a) => a.status === "available")) return;
if (Date.now() > until) throw new Error(`Attribute in ${collectionId} nach ${timeoutMs} ms nicht verfuegbar`);
await sleep(intervalMs);
}
}
async function createAttribute(db, collectionId, a) {
const req = Boolean(a.required);
switch (a.type) {
case "integer":
return db.createIntegerAttribute(databaseId, collectionId, a.key, req, a.min, a.max);
case "float":
return db.createFloatAttribute(databaseId, collectionId, a.key, req);
case "enum":
return db.createEnumAttribute(databaseId, collectionId, a.key, a.elements, req);
case "string[]":
return db.createStringAttribute(databaseId, collectionId, a.key, a.size, req, undefined, true);
default:
return db.createStringAttribute(databaseId, collectionId, a.key, a.size, req);
}
}
async function run() {
if (dryRun) {
for (const c of COLLECTIONS) {
console.log(`Sammlung ${c.id} (${c.perms.join(", ")}): ${c.attrs.map((a) => a.key).join(", ")}`);
}
console.log(`Bucket ${BUCKET.id}: ${BUCKET.extensions.join(", ")}, max ${BUCKET.maxSize} Bytes`);
for (const d of DOCUMENTS) console.log(`Rechtstext ${d.key}: ${d.title}`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases, Storage } = await import("node-appwrite");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const storage = new Storage(client);
for (const c of COLLECTIONS) {
try {
await db.getCollection(databaseId, c.id);
await db.updateCollection(databaseId, c.id, c.name, c.perms, false, true);
console.log("Sammlung vorhanden:", c.id);
} catch (e) {
if (e.code !== 404) throw e;
await db.createCollection(databaseId, c.id, c.name, c.perms, false, true);
console.log("Sammlung angelegt:", c.id);
}
const existing = new Set((await db.listAttributes(databaseId, c.id)).attributes.map((a) => a.key));
for (const a of c.attrs) {
if (existing.has(a.key)) continue;
await createAttribute(db, c.id, a);
console.log(" Attribut", a.key);
await sleep(400);
}
await waitForAttributes(db, c.id);
const indexes = new Set((await db.listIndexes(databaseId, c.id)).indexes.map((i) => i.key));
for (const idx of c.indexes || []) {
if (indexes.has(idx.key)) continue;
await db.createIndex(databaseId, c.id, idx.key, idx.type, idx.attributes);
console.log(" Index", idx.key);
}
}
try {
await storage.getBucket(BUCKET.id);
console.log("Bucket vorhanden:", BUCKET.id);
} catch (e) {
if (e.code !== 404) throw e;
await storage.createBucket(BUCKET.id, BUCKET.name, BUCKET.perms, false, true, BUCKET.maxSize, BUCKET.extensions);
console.log("Bucket angelegt:", BUCKET.id);
}
for (const d of DOCUMENTS) {
try {
await db.getDocument(databaseId, "legalDocuments", d.key);
} catch (e) {
if (e.code !== 404) throw e;
await db.createDocument(databaseId, "legalDocuments", d.key, d);
console.log("Rechtstext angelegt:", d.key);
}
}
console.log("Fertig.");
}
// Nur direkt aufgerufen ausfuehren; seed-legal.mjs importiert DOCUMENTS.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* WOMS Unterstuetzung – Appwrite-Setup (idempotent).
* Legt in der Sammlung worksheets das Attribut "supporters" an (JSON-Liste der
* unterstuetzenden Mitarbeiter, siehe src/lib/support.js).
*
* Usage:
* node scripts/setup-supporters.mjs --dry-run
* APPWRITE_API_KEY=... node scripts/setup-supporters.mjs
*/
import { pathToFileURL } from "node:url";
const dryRun = process.argv.includes("--dry-run");
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
const collectionId = "worksheets";
export const ATTRIBUTE = { key: "supporters", type: "string", size: 4000, required: false };
/** 'create' = fehlt, 'exists' = passt, 'conflict' = gleicher Name mit anderem Typ oder zu klein */
export function planAttribute(existing) {
const found = (existing || []).find((a) => a.key === ATTRIBUTE.key);
if (!found) return "create";
if (found.type !== ATTRIBUTE.type || !(Number(found.size) >= ATTRIBUTE.size)) return "conflict";
return "exists";
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function run() {
if (dryRun) {
console.log(`Sammlung ${collectionId}: Attribut ${ATTRIBUTE.key} (String ${ATTRIBUTE.size}, nicht Pflicht) wird angelegt, falls es fehlt.`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases } = await import("node-appwrite");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const { attributes } = await db.listAttributes(databaseId, collectionId);
const plan = planAttribute(attributes);
if (plan === "conflict") {
console.error(`Attribut ${ATTRIBUTE.key} existiert schon mit anderem Typ oder kleinerer Größe – bitte in der Appwrite-Konsole prüfen.`);
process.exit(1);
}
if (plan === "exists") {
console.log(`Attribut ${ATTRIBUTE.key} ist schon vorhanden.`);
return;
}
await db.createStringAttribute(databaseId, collectionId, ATTRIBUTE.key, ATTRIBUTE.size, ATTRIBUTE.required);
console.log(`Attribut ${ATTRIBUTE.key} angelegt, warte auf Appwrite …`);
const until = Date.now() + 30000;
for (;;) {
const attr = await db.getAttribute(databaseId, collectionId, ATTRIBUTE.key);
if (attr.status === "available") break;
if (attr.status === "failed" || attr.status === "stuck") throw new Error(`Attribut fehlgeschlagen: ${attr.error || attr.status}`);
if (Date.now() > until) throw new Error("Attribut nach 30 s nicht verfügbar – Status in der Appwrite-Konsole prüfen.");
await sleep(1000);
}
console.log(`Attribut ${ATTRIBUTE.key}: available`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
}
+55 -82
View File
@@ -1,126 +1,99 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Navbar from './components/Navbar'
import Sidebar from './components/Navbar'
import Footer from './components/Footer'
import PixelBlast from './components/PixelBlast'
import LoginPage from './pages/LoginPage'
import TicketsPage from './pages/TicketsPage'
import DashboardPage from './pages/DashboardPage'
import AssetsPage from './pages/AssetsPage'
import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage'
import ReportsPage from './pages/ReportsPage'
import DocsPage from './pages/DocsPage'
import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import CustomerChatPage from './pages/CustomerChatPage'
import StorageTasksPage from './pages/StorageTasksPage'
import CreditsPage from './pages/CreditsPage'
import OffersPage from './pages/OffersPage'
import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage'
import ServerpulsPage from './pages/ServerpulsPage'
import BankCallbackPage from './pages/BankCallbackPage'
import AdminRoute from './components/AdminRoute'
import { safeReturnPath } from './lib/returnPath'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<div className="text-center p-4">
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<p>Loading...</p>
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
}
function AppRoutes() {
const { user } = useAuth()
const location = useLocation()
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
<Route path="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
<Route path="/customers/email-speicher" element={<ProtectedRoute><StorageTasksPage /></ProtectedRoute>} />
<Route path="/customers/gutschriften" element={<ProtectedRoute><CreditsPage /></ProtectedRoute>} />
<Route path="/customers/angebote" element={<ProtectedRoute><OffersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} />
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
<Route path="/services" element={<ProtectedRoute><ServicesPage /></ProtectedRoute>} />
<Route path="/projects/zuordnen/:id" element={<ProtectedRoute><AssignProjectPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */}
<Route path="*" element={<Navigate to="/tickets" replace />} />
</Routes>
)
}
function AppContent() {
const { user } = useAuth()
if (!user) {
return (
<>
{/* PixelBlast Background für Login-Seite */}
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 0,
background: '#1a202c'
}}>
<PixelBlast
color="#10b981"
variant="circle"
pixelSize={4}
patternScale={2}
patternDensity={0.8}
enableRipples={true}
rippleIntensityScale={1.5}
speed={0.3}
edgeFade={0.3}
/>
</div>
<div style={{ position: 'relative', zIndex: 1 }}>
return <AppRoutes />
}
return (
<div className="app-shell">
<Sidebar />
<div className="app-main">
<div className="app-scroll">
<AppRoutes />
</div>
</>
)
}
return (
<>
{/* PixelBlast Background für Haupt-App */}
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 0,
background: '#1a202c'
}}>
<PixelBlast
color="#10b981"
variant="circle"
pixelSize={4}
patternScale={2}
patternDensity={0.8}
enableRipples={true}
rippleIntensityScale={1.5}
speed={0.3}
edgeFade={0.3}
/>
<Footer />
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Navbar />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto' }}>
<AppRoutes />
</div>
<Footer />
</div>
</div>
</>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
Lato Regular 1.105 (lato-regular.ttf)
Copyright (c) 2010-2013 by tyPoland Lukasz Dziedzic with Reserved Font Name "Lato".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
// Nur fuer Admins; wird innerhalb von ProtectedRoute verwendet. Alle anderen landen bei den Tickets.
export default function AdminRoute({ children }) {
const { loading, isAdmin } = useAuth()
if (loading) {
return (
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<p>Wird geladen …</p>
</div>
)
}
if (!isAdmin) return <Navigate to="/tickets" replace />
return children
}
+133
View File
@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
const COLORS = { green: '#10b981', red: '#ef4444', grey: '#a0aec0' }
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
export default function AnalyseRow({ projectId }) {
const [data, setData] = useState(null)
const [loadError, setLoadError] = useState('')
const [mode, setMode] = useState(null) // null | 'on' | 'off'
const [confirmed, setConfirmed] = useState(false)
const [date, setDate] = useState('')
const [channel, setChannel] = useState('')
const [copied, setCopied] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
fetchAnalyse(projectId)
.then((r) => { if (!cancelled) setData(r) })
.catch((err) => { if (!cancelled) setLoadError(err.message) })
return () => { cancelled = true }
}, [projectId])
const apply = async (on) => {
setBusy(true)
setError('')
try {
await setAnalyse(projectId, { on, textformDate: date, textformChannel: channel, audienceConfirmed: on })
setData(await fetchAnalyse(projectId))
setMode(null)
setConfirmed(false)
setDate('')
setChannel('')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const cancel = () => { setMode(null); setConfirmed(false); setDate(''); setChannel(''); setError('') }
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
const minDate = data?.avvVersion || ''
const privacyBlocked = data?.privacy?.ok === false
const canSubmit = !privacyBlocked && canSubmitTextform({ confirmed, date, channel, today, minDate })
const copyTemplate = async () => {
try {
await navigator.clipboard.writeText(chatTemplate(data.avvVersion))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
}
const status = analyseStatus(data)
return (
<div style={{ marginTop: 10, fontSize: 13 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="text-grey">Website-Analyse:</span>
{loadError ? (
<span style={{ color: COLORS.red }}>{loadError}</span>
) : data ? (
<span style={{ color: COLORS[status.color] }}>{status.text}</span>
) : (
<span className="text-grey">Lädt …</span>
)}
{status.hint && <span className="text-grey">({status.hint})</span>}
{status.canEnable && !mode && (
<button type="button" className="btn btn-sm" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
)}
{status.canDisable && !mode && (
<button type="button" className="btn btn-sm" onClick={() => setMode('off')}>Ausschalten</button>
)}
</div>
{mode === 'on' && data && (() => {
const label = textformLabel(data.avvVersion)
const links = legalLinks(data.avvVersion)
return (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} style={{ marginTop: 3 }} />
<span>
{label.before}
<a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
{label.between}
<a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
{label.after}
</span>
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<label>
Datum der Zustimmung:{' '}
<input type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
</label>
<span role="radiogroup" aria-label="Weg der Zustimmung" style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
<span>Weg:</span>
{['chat', 'email'].map((c) => (
<label key={c} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
{channelLabel(c)}
</label>
))}
</span>
</div>
{privacyBlocked && <div style={{ color: COLORS.red }}>{privacyHint(data.privacy)}</div>}
<details>
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{chatTemplate(data.avvVersion)}</p>
<button type="button" className="btn btn-sm" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</button>
</details>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sm" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
</div>
</div>
)
})()}
{mode === 'off' && (
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span>Website-Analyse wirklich ausschalten?</span>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
</div>
)}
{error && <div style={{ marginTop: 6, color: COLORS.red }}>{error}</div>}
</div>
)
}
+177
View File
@@ -0,0 +1,177 @@
import { useState } from 'react'
import { FaCodeBranch } from 'react-icons/fa'
import { FaLinkSlash } from 'react-icons/fa6'
import PreviewBlock from './PreviewBlock'
import AnalyseRow from './AnalyseRow'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
import { suggestSubdomain } from '../lib/subdomain'
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
const STATUS_COLORS = {
ready: '#10b981',
live: '#10b981',
online: '#10b981',
provisioning: '#f59e0b',
pending: '#f59e0b',
building: '#3b82f6',
error: '#ef4444',
failed: '#ef4444',
}
function StatusBadge({ label, value }) {
if (!value) return null
const color = STATUS_COLORS[String(value).toLowerCase()] || '#a0aec0'
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e0', marginRight: 12 }}>
{label}:
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
{value}
</span>
</span>
)
}
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
const [showPreviewInput, setShowPreviewInput] = useState(false)
const [subdomain, setSubdomain] = useState('')
const [previewBusy, setPreviewBusy] = useState(false)
const [previewMessage, setPreviewMessage] = useState('')
const [pushBusy, setPushBusy] = useState(false)
const [pushMessage, setPushMessage] = useState('')
const [showPreviewBlock, setShowPreviewBlock] = useState(false)
const openPreviewInput = () => {
setSubdomain(suggestSubdomain(customerName, project.projectName))
setPreviewMessage('')
setShowPreviewInput(true)
}
const handleEnablePreview = async () => {
setPreviewBusy(true)
setPreviewMessage('')
try {
const r = await updateWebsiteProject(project.$id, { projectType: 'preview', subdomain })
setPreviewMessage(`Preview läuft: ${r.previewUrl}`)
setShowPreviewInput(false)
onChanged()
} catch (err) {
setPreviewMessage(err.message)
} finally {
setPreviewBusy(false)
}
}
const handleBackfill = async () => {
setPushBusy(true)
setPushMessage('')
try {
const r = await backfillProjectPushes(project.$id)
setPushMessage(assignSummary(project.projectName, r).text)
onChanged()
} catch (err) {
setPushMessage(err.message)
} finally {
setPushBusy(false)
}
}
return (
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div style={{ minWidth: 0 }}>
<strong>{project.projectName}</strong>
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
<GiteaLinkButton href={project.giteaRepoUrl} />
{showActions && onUnassign && (
<button
type="button"
title="Vom Ticket lösen"
aria-label="Vom Ticket lösen"
onClick={() => onUnassign(project.$id)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', color: '#f87171', cursor: 'pointer' }}
>
<FaLinkSlash size={16} />
</button>
)}
</div>
</div>
{/* Status- und Provisioning-Zeile */}
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
<StatusBadge label="Status" value={project.status} />
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
</div>
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
{showActions && project.subdomain && (
<div style={{ marginTop: 10 }}>
<button
type="button"
className="btn btn-sm"
aria-expanded={showPreviewBlock}
onClick={() => setShowPreviewBlock((v) => !v)}
>
Preview {showPreviewBlock ? '▴' : '▾'}
</button>
{showPreviewBlock && (
<div style={{ marginTop: 8 }}>
<PreviewBlock key={project.$id} projectId={project.$id} />
</div>
)}
</div>
)}
{showActions && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{needsPreviewButton(project) && !showPreviewInput && (
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
Preview einschalten
</button>
)}
{showPreviewInput && (
<>
<input
type="text"
className="form-control"
style={{ maxWidth: 200 }}
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
disabled={!subdomain || previewBusy}
onClick={handleEnablePreview}
>
Einschalten
</button>
</>
)}
<button type="button" className="btn btn-sm" disabled={pushBusy} onClick={handleBackfill}>
Pushes nachtragen
</button>
</div>
{(previewMessage || pushMessage) && (
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
{previewMessage && <div>{previewMessage}</div>}
{pushMessage && <div>{pushMessage}</div>}
</div>
)}
</div>
)}
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
export async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
}
export default function CopyableCredential({ value, secret = false, label = 'Wert' }) {
const [visible, setVisible] = useState(!secret)
const [copied, setCopied] = useState(false)
const timeoutRef = useRef(null)
const text = String(value || '').trim()
useEffect(() => {
setVisible(!secret)
}, [secret, text])
useEffect(() => () => clearTimeout(timeoutRef.current), [])
if (!text) return <span className="credential-empty">–</span>
const handleCopy = async (event) => {
event.preventDefault()
event.stopPropagation()
await copyText(text)
setCopied(true)
clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => setCopied(false), 1800)
}
const displayValue = secret && !visible ? '••••••••••••' : text
return (
<span className="credential">
<span
className={`credential-value ${secret && !visible ? 'is-masked' : ''}`}
title={secret && !visible ? `${label} anzeigen` : text}
>
{displayValue}
</span>
{secret && (
<button
type="button"
className="credential-action"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setVisible((current) => !current)
}}
title={visible ? `${label} ausblenden` : `${label} anzeigen`}
aria-label={visible ? `${label} ausblenden` : `${label} anzeigen`}
>
{visible ? <FaEyeSlash /> : <FaEye />}
</button>
)}
<button
type="button"
className="credential-action"
onClick={handleCopy}
title={copied ? 'Kopiert' : `${label} kopieren`}
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
>
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
</button>
</span>
)
}
export function getCustomerPortalPassword(customer) {
return customer?.portalPassword || customer?.password || ''
}
+133 -271
View File
@@ -1,90 +1,75 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAdminConfig } from '../hooks/useAdminConfig'
import { useEmployees } from '../hooks/useEmployees'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import {
SERVICE_TYPES,
todayIso,
isoToGermanDate,
timeToWoms,
buildRequesterSuggestions,
rankSuggestions,
} from '../lib/ticketForm'
import { priorityFor } from '../lib/ticketStatus'
import { AutocompleteInput, DateTimeInput } from './ui'
// Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
const TICKET_TYPES = [
{ value: 'Webpage', label: 'Webpage' },
{ value: 'Migration', label: 'Migration (Übernahme von anderem Anbieter)' },
]
const DEFAULT_SYSTEMS = [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
]
const DEFAULT_RESPONSE_LEVELS = [
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
]
const DEFAULT_SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
const DEFAULT_PRIORITIES = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Critical' }
]
const today = new Date().toLocaleDateString('de-DE')
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
// Die Service-Art fragt das Formular nicht mehr ab: gespeichert wird Fernwartung.
const emptyForm = () => ({
customerId: '',
type: TICKET_TYPES[0].value,
serviceType: SERVICE_TYPES[0].value,
urgent: false,
topic: '',
requestedBy: '',
startDate: todayIso(),
startTime: '',
deadline: todayIso(),
endTime: '',
details: ''
})
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
const { config } = useAdminConfig()
const { employees } = useEmployees()
// Verwende Config-Werte oder Fallbacks
const TICKET_TYPES = config?.ticketTypes || DEFAULT_TICKET_TYPES
const SYSTEMS = config?.systems || DEFAULT_SYSTEMS
const RESPONSE_LEVELS = config?.responseLevels || DEFAULT_RESPONSE_LEVELS
const SERVICE_TYPES = config?.serviceTypes || DEFAULT_SERVICE_TYPES
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
const [formData, setFormData] = useState({
customerId: '',
type: '',
systemType: '',
responseLevel: '',
serviceType: '',
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
const [formData, setFormData] = useState(emptyForm)
const [recentTickets, setRecentTickets] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Setze Default-Werte wenn Config geladen ist oder Modal geöffnet wird
// Reset error when modal opens
useEffect(() => {
if (isOpen && (TICKET_TYPES.length > 0 || SERVICE_TYPES.length > 0)) {
setFormData(prev => ({
...prev,
type: prev.type || TICKET_TYPES[0] || 'Supportrequest',
serviceType: prev.serviceType || SERVICE_TYPES[0] || 'Remote',
priority: prev.priority || (PRIORITIES.find(p => p.value === 1)?.value || 1)
}))
if (isOpen) setError('')
}, [isOpen])
// Namen aus den letzten Tickets fuer die Autovervollstaendigung
useEffect(() => {
if (!isOpen) return
let cancelled = false
databases
.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(200)])
.then((response) => {
if (!cancelled) setRecentTickets(response.documents || [])
})
.catch((err) => console.warn('Vorschläge aus früheren Tickets nicht geladen:', err))
return () => {
cancelled = true
}
// Reset error when modal opens
if (isOpen) {
setError('')
}
}, [isOpen, TICKET_TYPES, SERVICE_TYPES, PRIORITIES])
}, [isOpen])
const suggestions = useMemo(
() => buildRequesterSuggestions({ customers, employees, tickets: recentTickets }),
[customers, employees, recentTickets]
)
const getSuggestions = (text) => rankSuggestions(suggestions, text, { customerId: formData.customerId })
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
@@ -92,34 +77,37 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
if (error) setError('')
}
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
const handleStartDateChange = (value) => {
setFormData(prev => ({
...prev,
startDate: value,
deadline: value && prev.deadline && prev.deadline < value ? value : prev.deadline
}))
if (error) setError('')
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
const customer = customers.find(c => c.$id === formData.customerId)
const { urgent, ...fields } = formData
try {
const result = await onCreate(formData)
const result = await onCreate({
...fields,
priority: priorityFor(urgent),
customerName: customer?.name || '',
customerLocation: customer?.location || '',
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
deadline: isoToGermanDate(formData.deadline),
endTime: timeToWoms(formData.endTime)
})
if (result.success) {
onClose()
setFormData({
customerId: '',
type: TICKET_TYPES[0] || 'Supportrequest',
systemType: '',
responseLevel: '',
serviceType: SERVICE_TYPES[0] || 'Remote',
priority: PRIORITIES.find(p => p.value === 1)?.value || 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
setFormData(emptyForm())
} else {
setError(result.error || 'Fehler beim Erstellen des Tickets')
}
@@ -139,26 +127,26 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New Ticket</h2>
<h2 className="mb-2">Neues Ticket</h2>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Customer ID</label>
<label className="form-label">Kunde</label>
<select
className="form-control"
value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)}
required
>
<option value="">Affected Customer</option>
<option value="">Kunde wählen</option>
{customers.map(c => (
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
@@ -166,109 +154,46 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</div>
<div className="form-group">
<label className="form-label">Work Order Type</label>
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Affected System</label>
<select
className="form-control"
value={formData.systemType}
onChange={(e) => handleChange('systemType', e.target.value)}
required
>
<option value="">Affected System</option>
{SYSTEMS.map(sys => (
<option key={sys} value={sys}>{sys}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
</div>
<div className="form-group">
<label className="form-label">Response Level</label>
<select
className="form-control"
value={formData.responseLevel}
onChange={(e) => handleChange('responseLevel', e.target.value)}
>
<option value="">Response Level</option>
{RESPONSE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Service Type</label>
<select
className="form-control"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
>
{SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Priority</label>
<select
className="form-control"
value={formData.priority}
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
required
>
<option value="">Priority Level</option>
{PRIORITIES.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Assigned To</label>
<select
className="form-control"
value={formData.assignedTo}
onChange={(e) => {
const userId = e.target.value
handleChange('assignedTo', userId)
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned"
// Wenn kein Mitarbeiter → Status = "Open"
if (userId) {
handleChange('status', 'Assigned')
} else {
handleChange('status', 'Open')
}
}}
>
<option value="">Unassigned</option>
{employees.map(emp => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
</option>
))}
</select>
<div className="form-group automatic-assignment-note">
<label className="form-label">Zuweisung</label>
<p>
{employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
</p>
</div>
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label">Topic</label>
<label className="form-label">Betreff</label>
<input
type="text"
className="form-control"
placeholder="Topic"
placeholder="Worum geht es?"
value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)}
required
@@ -276,125 +201,62 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</div>
<div className="form-group">
<label className="form-label">Requested by</label>
<input
type="text"
className="form-control"
<label className="form-label" htmlFor="ticket-requested-by">Angefragt von</label>
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(e) => handleChange('requestedBy', e.target.value)}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</div>
<div className="form-group">
<label className="form-label">Requested for</label>
<input
type="text"
className="form-control"
placeholder="Name"
value={formData.requestedFor}
onChange={(e) => handleChange('requestedFor', e.target.value)}
<label className="form-label" htmlFor="ticket-start-date">Beginn</label>
<DateTimeInput
id="ticket-start-date"
timeLabel="Uhrzeit Beginn"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label">Start Date & Time</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
style={{ flex: 1 }}
/>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
style={{ flex: 1 }}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">End Date & Time (Deadline)</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.deadline}
onChange={(e) => handleChange('deadline', e.target.value)}
style={{ flex: 1 }}
/>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
style={{ flex: 1 }}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">Workload Estimate</label>
<input
type="text"
className="form-control"
placeholder="xx minutes"
value={formData.estimate}
onChange={(e) => handleChange('estimate', e.target.value)}
<label className="form-label" htmlFor="ticket-end-date">Deadline</label>
<DateTimeInput
id="ticket-end-date"
timeLabel="Uhrzeit Deadline"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label">Notification Recipient(s)</label>
<input
type="text"
className="form-control"
placeholder="name@domain.xx"
value={formData.mailCopyTo}
onChange={(e) => handleChange('mailCopyTo', e.target.value)}
/>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData.sendNotification}
onChange={(e) => handleChange('sendNotification', e.target.checked)}
/>
Send Notification?
</label>
</div>
</div>
</div>
<div className="form-group">
<label className="form-label">Details</label>
<label className="form-label">Beschreibung</label>
<textarea
className="form-control"
rows={5}
placeholder="Details"
placeholder="Was soll gemacht werden?"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
/>
</div>
<div className="text-center mt-2">
<button
type="submit"
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Creating...' : 'CREATE NOW'}
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</button>
</div>
</form>
+155 -176
View File
@@ -1,55 +1,52 @@
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAuth } from '../context/AuthContext'
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
import { DateTimeInput, DurationSelect } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
const STATUS_OPTIONS = [
'Open',
'Closed',
'Awaiting',
'Added Info',
'Occupied',
'Halted',
'Cancelled',
'Aborted',
'Assigned',
'In Test'
]
// Die sechs Status; ein alter Status (z. B. "Assigned") bleibt als aktuelle Auswahl sichtbar
function statusOptionsFor(current) {
if (!current || TICKET_STATUSES.some((s) => s.value === current)) return TICKET_STATUSES
return [{ value: current, label: `${statusLabel(current)} (bisher)` }, ...TICKET_STATUSES]
}
const RESPONSE_LEVELS = [
'KEY USER',
'1st Level',
'2nd Level',
'3rd Level',
'FS/FE',
'24/7',
'TECH MGMT',
'Backoffice',
'BUSI MGMT',
'n/a'
]
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
// Die Service-Art kommt vom Ticket; das alte "Off Site" zaehlt als Fernwartung.
const initialForm = (workorder, newStatus) => ({
serviceType: SERVICE_TYPES.some(t => t.value === workorder?.serviceType)
? workorder.serviceType
: SERVICE_TYPES[0].value,
newStatus,
totalTime: 0,
startDate: todayIso(),
startTime: '',
endDate: todayIso(),
endTime: '',
details: '',
isComment: false,
countsToPlan: false,
supporters: []
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true }) {
const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
const [formData, setFormData] = useState({
serviceType: 'Remote',
newStatus: workorder?.status || 'Open',
newResponseLevel: workorder?.responseLevel || '',
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false
})
const isAcquisition = workorder?.type === 'Akquise'
const statusOptions = isAcquisition
? ACQUISITION_STATUS.map((s) => ({ value: s, label: s }))
: statusOptionsFor(workorder?.status)
const defaultStatus = isAcquisition
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
: (workorder?.status || 'Open')
const [formData, setFormData] = useState(() => initialForm(workorder, defaultStatus))
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -58,69 +55,62 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
// Reset form wenn Modal geöffnet wird
useEffect(() => {
if (isOpen && workorder) {
setFormData({
serviceType: 'Remote',
newStatus: workorder.status || 'Open',
newResponseLevel: workorder.responseLevel || '',
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false
})
setFormData(initialForm(workorder, defaultStatus))
setError('')
setAutoCalculate(true)
}
}, [isOpen, workorder, today])
}, [isOpen, workorder, defaultStatus])
// Automatische Zeitberechnung
// Automatische Zeitberechnung, aufgerundet auf 15 Minuten
useEffect(() => {
if (autoCalculate && formData.startTime && formData.endTime && !formData.isComment) {
try {
const startHour = parseInt(formData.startTime.substring(0, 2))
const startMin = parseInt(formData.startTime.substring(2, 4))
const endHour = parseInt(formData.endTime.substring(0, 2))
const endMin = parseInt(formData.endTime.substring(2, 4))
if (!isNaN(startHour) && !isNaN(startMin) && !isNaN(endHour) && !isNaN(endMin)) {
const startTotal = startHour * 60 + startMin
const endTotal = endHour * 60 + endMin
let diff = endTotal - startTotal
if (diff < 0) {
diff += 24 * 60 // Overnight
}
setFormData(prev => ({ ...prev, totalTime: diff }))
}
} catch (err) {
// Ignoriere Fehler
}
if (!autoCalculate || formData.isComment) return
const minutes = minutesBetween(formData.startTime, formData.endTime)
if (minutes !== null) {
setFormData(prev => ({ ...prev, totalTime: roundUpToStep(minutes) }))
}
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung
if (field === 'totalTime') {
setAutoCalculate(false)
}
}
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
const handleStartDateChange = (value) => {
setFormData(prev => ({
...prev,
startDate: value,
endDate: value && prev.endDate && prev.endDate < value ? value : prev.endDate
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
if (!formData.details.trim()) {
setError('Bitte Details eingeben')
setLoading(false)
return
}
if (!formData.isComment && !isValidStep(formData.totalTime)) {
setError('Bitte die Arbeitszeit auswählen.')
setLoading(false)
return
}
const supporters = formData.isComment ? [] : formData.supporters
const supportError = validateSupporters(supporters, user?.$id)
if (supportError) {
setError(supportError)
setLoading(false)
return
}
const worksheetData = {
woid: workorder.woid,
@@ -128,24 +118,24 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
serviceType: formData.serviceType,
oldStatus: workorder.status,
newStatus: formData.newStatus,
oldResponseLevel: workorder.responseLevel || '',
newResponseLevel: formData.newResponseLevel,
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
startDate: formData.startDate,
startTime: formData.startTime,
endDate: formData.endDate,
endTime: formData.endTime,
totalTime: formData.isComment ? 0 : Number(formData.totalTime),
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
endDate: isoToGermanDate(formData.endDate),
endTime: timeToWoms(formData.endTime),
details: formData.details,
isComment: formData.isComment,
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
countsToPlan: aboHoursAllowed && !formData.isComment && formData.countsToPlan,
employeeShort: user?.prefs?.shortCode || '', // Aus User-Preferences
supporters
}
const result = await onCreate(worksheetData, user)
if (result.success) {
onClose()
} else {
setError(result.error || 'Fehler beim Erstellen des Worksheets')
setError(result.error || 'Fehler beim Speichern des Arbeitsblatts')
}
} catch (err) {
console.error('Error creating worksheet:', err)
@@ -163,19 +153,19 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2>
<h2 className="mb-2">Neues Arbeitsblatt – Ticket {workorder.woid}</h2>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Service Type</label>
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.serviceType}
@@ -183,37 +173,28 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
required
>
{SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">New Status</label>
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{STATUS_OPTIONS.map(status => (
<option key={status} value={status}>{status}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">New Response Level</label>
<select
className="form-control"
value={formData.newResponseLevel}
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
>
<option value="">Select Response Level</option>
{RESPONSE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>{status.label}</option>
))}
</select>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
@@ -226,80 +207,78 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
{aboHoursAllowed && (
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
Vom Abo-Stundenkonto abziehen
</label>
</div>
)}
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label">Total Time (Minuten)</label>
<input
type="number"
className="form-control"
min="0"
step="15"
value={formData.totalTime}
onChange={(e) => handleChange('totalTime', e.target.value)}
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
<DateTimeInput
id="worksheet-start-date"
timeLabel="Uhrzeit Beginn"
required
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
<DateTimeInput
id="worksheet-end-date"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
placeholder="0"
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
{autoCalculate && formData.startTime && formData.endTime
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
</small>
</div>
<div className="form-group">
<label className="form-label">Start Date</label>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">End Date</label>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.endDate}
onChange={(e) => handleChange('endDate', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Start Time</label>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
maxLength="4"
/>
</div>
<div className="form-group">
<label className="form-label">End Time</label>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
maxLength="4"
/>
</div>
</div>
</div>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
/>
)}
<div className="form-group">
<label className="form-label">Action Details</label>
<label className="form-label">Was wurde gemacht?</label>
<textarea
className="form-control"
rows={5}
@@ -311,12 +290,12 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div>
<div className="text-center mt-2">
<button
type="submit"
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Creating...' : 'CREATE NOW'}
{loading ? 'Speichert …' : 'Speichern'}
</button>
</div>
</form>
-96
View File
@@ -1,96 +0,0 @@
import { useState, useRef } from 'react'
import { FaTimes, FaSpinner } from 'react-icons/fa'
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
export default function FileUploadModal({ isOpen, onClose, workorderId, onUploadComplete }) {
const [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef(null)
const handleDrop = async (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
await uploadFile(file)
}
}
const handleFileSelect = async (e) => {
const file = e.target.files[0]
if (file) {
await uploadFile(file)
}
}
const uploadFile = async (file) => {
// Validate file type
const allowedTypes = ['application/pdf', 'image/gif', 'image/png', 'image/jpeg']
if (!allowedTypes.includes(file.type)) {
alert('Only PDF, GIF, PNG, and JPEG files are allowed.')
return
}
setUploading(true)
try {
const response = await storage.createFile(
BUCKET_ID,
ID.unique(),
file
)
onUploadComplete?.(response)
onClose()
} catch (error) {
console.error('Upload error:', error)
alert('Error uploading file: ' + error.message)
} finally {
setUploading(false)
}
}
if (!isOpen) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="text-center mb-2">UPLOAD AREA</h2>
<p className="mb-2">Upload Attachments for WOID {workorderId}</p>
<div
className={`drop-zone ${dragOver ? 'border-green' : ''}`}
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? (
<div>
<FaSpinner className="spinner" size={32} />
<p className="mt-1">Uploading file...</p>
</div>
) : (
<>
<p>Drop file here (.pdf, .gif, .png, .jpg, .jpeg)</p>
<p className="mt-1">or</p>
<button className="btn btn-dark mt-1">Select File</button>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.gif,.png,.jpg,.jpeg"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
</div>
</div>
)
}
+2 -6
View File
@@ -1,12 +1,8 @@
export default function Footer() {
return (
<footer className="footer">
<p>
WOMS 2.0 - Work Order Management System |
Powered by <a href="https://appwrite.io" target="_blank" rel="noopener noreferrer">Appwrite</a> & React
</p>
<p className="text-small text-grey">
© {new Date().getFullYear()} - All rights reserved
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Webklar Ticket-Plattform · © {new Date().getFullYear()}
</p>
</footer>
)
+30
View File
@@ -0,0 +1,30 @@
import { SiGitea } from 'react-icons/si'
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
if (!href) return null
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={title}
aria-label={title}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: 'rgba(99, 102, 241, 0.15)',
border: '1px solid rgba(99, 102, 241, 0.35)',
color: '#818cf8',
textDecoration: 'none',
flexShrink: 0,
}}
>
<SiGitea size={size} />
</a>
)
}
-138
View File
@@ -1,138 +0,0 @@
/* Modern Sidebar Styles */
.modern-sidebar-desktop {
height: 100vh;
padding: 1rem;
display: none;
flex-direction: column;
background: var(--sidebar-bg, #f5f5f5);
border-right: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
overflow: hidden;
transition: width 0.3s ease;
}
@media (min-width: 768px) {
.modern-sidebar-desktop {
display: flex;
}
}
/* Mobile Sidebar */
.modern-sidebar-mobile-header {
height: 60px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sidebar-bg, #f5f5f5);
border-bottom: 1px solid var(--border-color, #e0e0e0);
width: 100%;
}
@media (min-width: 768px) {
.modern-sidebar-mobile-header {
display: none;
}
}
.modern-sidebar-mobile-toggle {
display: flex;
justify-content: flex-end;
width: 100%;
cursor: pointer;
color: var(--text-color, #333);
}
.modern-sidebar-mobile-toggle svg {
width: 24px;
height: 24px;
}
.modern-sidebar-mobile-menu {
position: fixed;
height: 100vh;
width: 100%;
inset: 0;
background: var(--sidebar-bg, #fff);
padding: 2.5rem;
z-index: 100;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.modern-sidebar-mobile-close {
position: absolute;
right: 2.5rem;
top: 2.5rem;
z-index: 50;
cursor: pointer;
color: var(--text-color, #333);
}
.modern-sidebar-mobile-close svg {
width: 24px;
height: 24px;
}
/* Sidebar Link */
.modern-sidebar-link {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 0.5rem;
color: var(--text-color, #333);
text-decoration: none;
border-radius: 6px;
transition: all 0.15s ease;
}
.modern-sidebar-link:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
transform: translateX(2px);
}
.modern-sidebar-link-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modern-sidebar-link-icon svg {
width: 20px;
height: 20px;
}
.modern-sidebar-link-label {
font-size: 0.875rem;
white-space: pre;
margin: 0;
padding: 0;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.modern-sidebar-desktop,
.modern-sidebar-mobile-header,
.modern-sidebar-mobile-menu {
background: var(--dark-sidebar-bg, #1a1a1a);
border-color: var(--dark-border-color, #333);
}
.modern-sidebar-link {
color: var(--dark-text-color, #e0e0e0);
}
.modern-sidebar-link:hover {
background: var(--dark-hover-bg, rgba(255, 255, 255, 0.1));
}
.modern-sidebar-mobile-toggle,
.modern-sidebar-mobile-close {
color: var(--dark-text-color, #e0e0e0);
}
}
-116
View File
@@ -1,116 +0,0 @@
import { useState, createContext, useContext } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { IconMenu2, IconX } from '@tabler/icons-react'
import { cn } from '../lib/utils'
import './ModernSidebar.css'
const SidebarContext = createContext(undefined)
export const useSidebar = () => {
const context = useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider')
}
return context
}
export const SidebarProvider = ({ children, open: openProp, setOpen: setOpenProp, animate = true }) => {
const [openState, setOpenState] = useState(false)
const open = openProp !== undefined ? openProp : openState
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState
return (
<SidebarContext.Provider value={{ open, setOpen, animate }}>
{children}
</SidebarContext.Provider>
)
}
export const Sidebar = ({ children, open, setOpen, animate }) => {
return (
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
{children}
</SidebarProvider>
)
}
export const SidebarBody = ({ className, children, ...props }) => {
return (
<>
<DesktopSidebar className={className} {...props}>{children}</DesktopSidebar>
<MobileSidebar className={className}>{children}</MobileSidebar>
</>
)
}
export const DesktopSidebar = ({ className, children, ...props }) => {
const { open, setOpen, animate } = useSidebar()
return (
<motion.div
className={cn('modern-sidebar-desktop', className)}
animate={{
width: animate ? (open ? '300px' : '60px') : '300px',
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
{...props}
>
{children}
</motion.div>
)
}
export const MobileSidebar = ({ className, children }) => {
const { open, setOpen } = useSidebar()
return (
<>
<div className="modern-sidebar-mobile-header">
<div className="modern-sidebar-mobile-toggle">
<IconMenu2 onClick={() => setOpen(!open)} />
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ x: '-100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className={cn('modern-sidebar-mobile-menu', className)}
>
<div className="modern-sidebar-mobile-close" onClick={() => setOpen(!open)}>
<IconX />
</div>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</>
)
}
export const SidebarLink = ({ link, className, ...props }) => {
const { open, animate } = useSidebar()
return (
<a
href={link.href}
className={cn('modern-sidebar-link', className)}
{...props}
>
<span className="modern-sidebar-link-icon">{link.icon}</span>
<motion.span
animate={{
display: animate ? (open ? 'inline-block' : 'none') : 'inline-block',
opacity: animate ? (open ? 1 : 0) : 1,
}}
className="modern-sidebar-link-label"
>
{link.label}
</motion.span>
</a>
)
}
+136 -153
View File
@@ -1,174 +1,157 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { motion } from 'motion/react'
import {
IconTicket,
IconDeviceDesktop,
IconChartBar,
IconCalendar,
IconFolders,
IconFileText,
IconBook,
import {
IconTicket,
IconUsers,
IconTargetArrow,
IconReportMoney,
IconScale,
IconActivityHeartbeat,
IconFolders,
IconLayoutGrid,
IconCalendar,
IconSettings,
IconLogout
IconLogout,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMessages,
IconReceiptRefund,
IconDiscount,
IconMailCog,
} from '@tabler/icons-react'
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
import { buildNavLinks } from '../lib/navLinks'
import { useSidebarCounts } from '../hooks/useSidebarCounts'
export default function Navbar() {
const ICONS = {
tickets: <IconTicket />,
planboard: <IconCalendar />,
projects: <IconFolders />,
services: <IconLayoutGrid />,
customers: <IconUsers />,
chat: <IconMessages />,
gutschriften: <IconReceiptRefund />,
angebote: <IconDiscount />,
speicher: <IconMailCog />,
leads: <IconTargetArrow />,
finance: <IconReportMoney />,
legal: <IconScale />,
serverpuls: <IconActivityHeartbeat />,
admin: <IconSettings />,
}
export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const location = useLocation()
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [pulseLevel, setPulseLevel] = useState('ok')
const counts = useSidebarCounts()
// Serverpuls-Punkt: nur Admins, beim Laden und alle 5 Minuten; Fehler bleiben still
useEffect(() => {
if (!isAdmin) { setPulseLevel('ok'); return undefined }
let dead = false
const load = () => {
serverpulsApi.overview()
.then((o) => { if (!dead) setPulseLevel(o?.level || 'ok') })
.catch(() => { if (!dead) setPulseLevel('ok') })
}
load()
const iv = setInterval(load, 5 * 60 * 1000)
return () => { dead = true; clearInterval(iv) }
}, [isAdmin])
const handleLogout = async () => {
await logout()
navigate('/login')
}
const links = [
{
label: 'Tickets',
href: '/tickets',
icon: <IconTicket className="icon" />,
},
{
label: 'Assets',
href: '/assets',
icon: <IconDeviceDesktop className="icon" />,
},
{
label: 'Dashboard',
href: '/dashboard',
icon: <IconChartBar className="icon" />,
},
{
label: 'Planboard',
href: '/planboard',
icon: <IconCalendar className="icon" />,
},
{
label: 'Projects',
href: '/projects',
icon: <IconFolders className="icon" />,
},
{
label: 'Reports',
href: '/reports',
icon: <IconFileText className="icon" />,
},
{
label: 'Docs',
href: '/docs',
icon: <IconBook className="icon" />,
},
]
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
// Admin link nur wenn isAdmin
if (isAdmin) {
links.push({
label: 'Admin',
href: '/admin',
icon: <IconSettings className="icon" />,
})
}
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
// Finanzen bleibt auch auf der Rueckkehrseite der Bankfreigabe (/finanzen/...) markiert
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
return (
<Sidebar open={open} setOpen={setOpen}>
<SidebarBody className="justify-between gap-10">
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
<Logo />
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{links.map((link, idx) => (
<SidebarLink key={idx} link={link} />
))}
</div>
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
<div className="side-head">
<Link to="/" className="side-logo" title="Webklar">W</Link>
<div className="side-brand">
Webklar
<small>Ticket-Plattform</small>
</div>
<div>
{user ? (
<>
<SidebarLink
link={{
label: user.name || user.email,
href: '#',
icon: (
<div style={{
height: '28px',
width: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
flexShrink: 0
}}>
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
),
}}
/>
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
<SidebarLink
link={{
label: 'Logout',
href: '#',
icon: <IconLogout className="icon" />,
}}
/>
</div>
</>
</div>
<nav className="side-nav">
<div className="side-group">
{links.map((link) => link.paused ? (
<span
key={link.href}
className="side-link side-link-paused"
title={`${link.label} (pausiert)`}
aria-disabled="true"
>
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
</span>
) : (
<SidebarLink
link={{
label: 'Login',
href: '/login',
icon: <IconLogout className="icon" />,
}}
/>
)}
<Link
key={link.href}
to={link.href}
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
title={link.label}
>
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
{link.dot && link.dot !== 'ok' && (
<span
className={`side-dot side-dot-${link.dot}`}
data-testid="sp-nav-dot"
title={link.dot === 'critical' ? 'Critical-Warnung aktiv' : 'Warnung aktiv'}
/>
)}
</Link>
)).flatMap((item, i) => [
item,
...(links[i].children || []).map((child) => (
<Link
key={child.href}
to={child.href}
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : child.label}
>
{ICONS[child.key]}
<span className="side-link-label">{child.label}</span>
{child.count > 0 && <span className="side-count">{child.count}</span>}
</Link>
)),
])}
</div>
</SidebarBody>
</Sidebar>
)
}
</nav>
const Logo = () => {
return (
<Link
to="/"
style={{
position: 'relative',
zIndex: 20,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.25rem 0',
fontSize: '0.875rem',
fontWeight: 'normal',
color: 'var(--text-color, #333)',
textDecoration: 'none'
}}
>
<div style={{
height: '20px',
width: '24px',
flexShrink: 0,
borderRadius: '4px 2px 4px 2px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}} />
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
fontWeight: 500,
whiteSpace: 'pre',
color: 'var(--text-color, #333)'
}}
>
Webklar
</motion.span>
</Link>
<div className="side-foot">
{user && (
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</div>
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
</div>
)}
<button className="side-toggle" onClick={handleLogout} title="Logout">
<IconLogout size={20} />
<span className="side-link-label">Logout</span>
</button>
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
<span className="side-link-label">Einklappen</span>
</button>
</div>
</aside>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { parseSupporters, supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
export default function OwnSupportModal({ worksheet, currentUserId, onClose, onSave }) {
const existing = parseSupporters(worksheet.supporters).find((s) => s.employeeId === currentUserId)
const [task, setTask] = useState(existing?.task || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const save = async (value) => {
setSaving(true)
setError('')
const result = await onSave(worksheet, value)
if (result?.success) {
onClose()
return
}
setError(result?.error || 'Speichern fehlgeschlagen')
setSaving(false)
}
const handleSubmit = (e) => {
e.preventDefault()
if (!task.trim()) {
setError('Bitte angeben, wobei du unterstützt hast.')
return
}
save(task.trim())
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 480 }}>
<h2 className="mb-2">Unterstützung eintragen</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid} · {worksheet.employeeName}
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
</p>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="own-support-task">Wobei?</label>
<input
id="own-support-task"
className="form-control"
maxLength={200}
required
value={task}
onChange={(e) => setTask(e.target.value)}
autoFocus
/>
</div>
<div className="text-center mt-2 flex gap-2" style={{ justifyContent: 'center' }}>
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
{existing && (
<button type="button" className="btn" disabled={saving} onClick={() => save(null)}>
Eintrag entfernen
</button>
)}
</div>
</form>
</div>
</div>
)
}
-7
View File
@@ -1,7 +0,0 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
-674
View File
@@ -1,674 +0,0 @@
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
import './PixelBlast.css';
const createTouchTexture = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D context not available');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const trail = [];
let last = null;
const maxAge = 64;
let radius = 0.1 * size;
const speed = 1 / maxAge;
const clear = () => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const drawPoint = p => {
const pos = { x: p.x * size, y: (1 - p.y) * size };
let intensity = 1;
const easeOutSine = t => Math.sin((t * Math.PI) / 2);
const easeOutQuad = t => -t * (t - 2);
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
intensity *= p.force;
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
const offset = size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius;
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,0,0,1)';
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
ctx.fill();
};
const addTouch = norm => {
let force = 0;
let vx = 0;
let vy = 0;
if (last) {
const dx = norm.x - last.x;
const dy = norm.y - last.y;
if (dx === 0 && dy === 0) return;
const dd = dx * dx + dy * dy;
const d = Math.sqrt(dd);
vx = dx / (d || 1);
vy = dy / (d || 1);
force = Math.min(dd * 10000, 1);
}
last = { x: norm.x, y: norm.y };
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
};
const update = () => {
clear();
for (let i = trail.length - 1; i >= 0; i--) {
const point = trail[i];
const f = point.force * speed * (1 - point.age / maxAge);
point.x += point.vx * f;
point.y += point.vy * f;
point.age++;
if (point.age > maxAge) trail.splice(i, 1);
}
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
texture.needsUpdate = true;
};
return {
canvas,
texture,
addTouch,
update,
set radiusScale(v) {
radius = 0.1 * size * v;
},
get radiusScale() {
return radius / (0.1 * size);
},
size
};
};
const createLiquidEffect = (texture, opts) => {
const fragment = `
uniform sampler2D uTexture;
uniform float uStrength;
uniform float uTime;
uniform float uFreq;
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
float vx = tex.r * 2.0 - 1.0;
float vy = tex.g * 2.0 - 1.0;
float intensity = tex.b;
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
float amt = uStrength * intensity * wave;
uv += vec2(vx, vy) * amt;
}
`;
return new Effect('LiquidEffect', fragment, {
uniforms: new Map([
['uTexture', new THREE.Uniform(texture)],
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
['uTime', new THREE.Uniform(0)],
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
])
});
};
const SHAPE_MAP = {
square: 0,
circle: 1,
triangle: 2,
diamond: 3
};
const VERTEX_SRC = `
void main() {
gl_Position = vec4(position, 1.0);
}
`;
const FRAGMENT_SRC = `
precision highp float;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform int uEnableRipples;
uniform float uRippleSpeed;
uniform float uRippleThickness;
uniform float uRippleIntensity;
uniform float uEdgeFade;
uniform int uShapeType;
const int SHAPE_SQUARE = 0;
const int SHAPE_CIRCLE = 1;
const int SHAPE_TRIANGLE = 2;
const int SHAPE_DIAMOND = 3;
const int MAX_CLICKS = 10;
uniform vec2 uClickPos [MAX_CLICKS];
uniform float uClickTimes[MAX_CLICKS];
out vec4 fragColor;
float Bayer2(vec2 a) {
a = floor(a);
return fract(a.x / 2. + a.y * a.y * .75);
}
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
#define FBM_OCTAVES 5
#define FBM_LACUNARITY 1.25
#define FBM_GAIN 1.0
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p);
vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000, n100, w.x);
float x10 = mix(n010, n110, w.x);
float x01 = mix(n001, n101, w.x);
float x11 = mix(n011, n111, w.x);
float y0 = mix(x00, x10, w.y);
float y1 = mix(x01, x11, w.y);
return mix(y0, y1, w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * uScale, t);
float amp = 1.0;
float freq = 1.0;
float sum = 1.0;
for (int i = 0; i < FBM_OCTAVES; ++i){
sum += amp * vnoise(p * freq);
freq *= FBM_LACUNARITY;
amp *= FBM_GAIN;
}
return sum * 0.5 + 0.5;
}
float maskCircle(vec2 p, float cov){
float r = sqrt(cov) * .25;
float d = length(p - 0.5) - r;
float aa = 0.5 * fwidth(d);
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
}
float maskTriangle(vec2 p, vec2 id, float cov){
bool flip = mod(id.x + id.y, 2.0) > 0.5;
if (flip) p.x = 1.0 - p.x;
float r = sqrt(cov);
float d = p.y - r*(1.0 - p.x);
float aa = fwidth(d);
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
}
float maskDiamond(vec2 p, float cov){
float r = sqrt(cov) * 0.564;
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
}
void main(){
float pixelSize = uPixelSize;
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / pixelSize);
vec2 pixelUV = fract(fragCoord / pixelSize);
float cellPixelSize = 8.0 * pixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
base = base * 0.5 - 0.65;
float feed = base + (uDensity - 0.5) * 0.3;
float speed = uRippleSpeed;
float thickness = uRippleThickness;
const float dampT = 1.0;
const float dampR = 10.0;
if (uEnableRipples == 1) {
for (int i = 0; i < MAX_CLICKS; ++i){
vec2 pos = uClickPos[i];
if (pos.x < 0.0) continue;
float cellPixelSize = 8.0 * pixelSize;
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = speed * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten * uRippleIntensity);
}
}
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
float coverage = bw * jitterScale;
float M;
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
else M = coverage;
if (uEdgeFade > 0.0) {
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
M *= fade;
}
vec3 color = uColor;
fragColor = vec4(color, M);
}
`;
const MAX_CLICKS = 10;
const PixelBlast = ({
variant = 'square',
pixelSize = 3,
color = '#10b981',
className,
style,
antialias = true,
patternScale = 2,
patternDensity = 1,
liquid = false,
liquidStrength = 0.1,
liquidRadius = 1,
pixelSizeJitter = 0,
enableRipples = true,
rippleIntensityScale = 1,
rippleThickness = 0.1,
rippleSpeed = 0.3,
liquidWobbleSpeed = 4.5,
autoPauseOffscreen = true,
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0
}) => {
const containerRef = useRef(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef(null);
const prevConfigRef = useRef(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
const cfg = { antialias, liquid, noiseAmount };
let mustReinit = false;
if (!threeRef.current) mustReinit = true;
else if (prevConfigRef.current) {
for (const k of needsReinitKeys)
if (prevConfigRef.current[k] !== cfg[k]) {
mustReinit = true;
break;
}
}
if (mustReinit) {
if (threeRef.current) {
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
threeRef.current = null;
}
const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias,
alpha: true,
powerPreference: 'high-performance'
});
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
const uniforms = {
uResolution: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
uClickPos: {
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
},
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
uScale: { value: patternScale },
uDensity: { value: patternDensity },
uPixelJitter: { value: pixelSizeJitter },
uEnableRipples: { value: enableRipples ? 1 : 0 },
uRippleSpeed: { value: rippleSpeed },
uRippleThickness: { value: rippleThickness },
uRippleIntensity: { value: rippleIntensityScale },
uEdgeFade: { value: edgeFade }
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SRC,
fragmentShader: FRAGMENT_SRC,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3
});
const quadGeom = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(quadGeom, material);
scene.add(quad);
const clock = new THREE.Clock();
const setSize = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
renderer.setSize(w, h, false);
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
if (threeRef.current?.composer)
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
};
setSize();
const ro = new ResizeObserver(setSize);
ro.observe(container);
const randomFloat = () => {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const u32 = new Uint32Array(1);
window.crypto.getRandomValues(u32);
return u32[0] / 0xffffffff;
}
return Math.random();
};
const timeOffset = randomFloat() * 1000;
let composer;
let touch;
let liquidEffect;
if (liquid) {
touch = createTouchTexture();
touch.radiusScale = liquidRadius;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
liquidEffect = createLiquidEffect(touch.texture, {
strength: liquidStrength,
freq: liquidWobbleSpeed
});
const effectPass = new EffectPass(camera, liquidEffect);
effectPass.renderToScreen = true;
composer.addPass(renderPass);
composer.addPass(effectPass);
}
if (noiseAmount > 0) {
if (!composer) {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
}
const noiseEffect = new Effect(
'NoiseEffect',
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
{
uniforms: new Map([
['uTime', new THREE.Uniform(0)],
['uAmount', new THREE.Uniform(noiseAmount)]
])
}
);
const noisePass = new EffectPass(camera, noiseEffect);
noisePass.renderToScreen = true;
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
composer.addPass(noisePass);
}
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
const mapToPixels = e => {
const rect = renderer.domElement.getBoundingClientRect();
const scaleX = renderer.domElement.width / rect.width;
const scaleY = renderer.domElement.height / rect.height;
const fx = (e.clientX - rect.left) * scaleX;
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
return {
fx,
fy,
w: renderer.domElement.width,
h: renderer.domElement.height
};
};
const onPointerDown = e => {
const { fx, fy } = mapToPixels(e);
const ix = threeRef.current?.clickIx ?? 0;
uniforms.uClickPos.value[ix].set(fx, fy);
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
};
const onPointerMove = e => {
if (!touch) return;
const { fx, fy, w, h } = mapToPixels(e);
touch.addTouch({ x: fx / w, y: fy / h });
};
renderer.domElement.addEventListener('pointerdown', onPointerDown, {
passive: true
});
renderer.domElement.addEventListener('pointermove', onPointerMove, {
passive: true
});
let raf = 0;
const animate = () => {
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
return;
}
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) liquidEffect.uniforms.get('uTime').value = uniforms.uTime.value;
if (composer) {
if (touch) touch.update();
composer.passes.forEach(p => {
const effs = p.effects;
if (effs)
effs.forEach(eff => {
const u = eff.uniforms?.get('uTime');
if (u) u.value = uniforms.uTime.value;
});
});
composer.render();
} else renderer.render(scene, camera);
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
threeRef.current = {
renderer,
scene,
camera,
material,
clock,
clickIx: 0,
uniforms,
resizeObserver: ro,
raf,
quad,
timeOffset,
composer,
touch,
liquidEffect
};
} else {
const t = threeRef.current;
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
t.uniforms.uColor.value.set(color);
t.uniforms.uScale.value = patternScale;
t.uniforms.uDensity.value = patternDensity;
t.uniforms.uPixelJitter.value = pixelSizeJitter;
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
t.uniforms.uRippleThickness.value = rippleThickness;
t.uniforms.uRippleSpeed.value = rippleSpeed;
t.uniforms.uEdgeFade.value = edgeFade;
if (transparent) t.renderer.setClearAlpha(0);
else t.renderer.setClearColor(0x000000, 1);
if (t.liquidEffect) {
const uStrength = t.liquidEffect.uniforms.get('uStrength');
if (uStrength) uStrength.value = liquidStrength;
const uFreq = t.liquidEffect.uniforms.get('uFreq');
if (uFreq) uFreq.value = liquidWobbleSpeed;
}
if (t.touch) t.touch.radiusScale = liquidRadius;
}
prevConfigRef.current = cfg;
return () => {
if (threeRef.current && mustReinit) return;
if (!threeRef.current) return;
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
threeRef.current = null;
};
}, [
antialias,
liquid,
noiseAmount,
pixelSize,
patternScale,
patternDensity,
enableRipples,
rippleIntensityScale,
rippleThickness,
rippleSpeed,
pixelSizeJitter,
edgeFade,
transparent,
liquidStrength,
liquidRadius,
liquidWobbleSpeed,
autoPauseOffscreen,
variant,
color,
speed
]);
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;
+298
View File
@@ -0,0 +1,298 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { modeOptionsFor, previewStatus, modeHint, isDevUnavailableError, NO_DEV_HINT } from '../lib/previewView'
import { isValidEnvName, buildEnvPayload, isDevPreview, dotColor, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
import {
fetchPreview,
setPreviewMode,
savePreviewEnv,
rebuildPreview,
startPreviewDev,
stopPreviewDev,
fetchPreviewLog,
} from '../lib/previewApi'
const STATE_OF_COLOR = { green: 'online', yellow: 'building', red: 'failed', grey: 'off' }
const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
const logDate = (at) => {
const d = new Date(at)
if (!at || Number.isNaN(d.getTime())) return String(at || '')
return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export default function PreviewBlock({ projectId }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [inactive, setInactive] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [logText, setLogText] = useState('')
const [logFile, setLogFile] = useState('')
const [showLogs, setShowLogs] = useState(false)
// Variablen: Änderungen werden gesammelt und mit "Variablen speichern" übertragen
const [pendingSet, setPendingSet] = useState({})
const [pendingRemove, setPendingRemove] = useState([])
const [newName, setNewName] = useState('')
const [newValue, setNewValue] = useState('')
const [editName, setEditName] = useState('')
const [editValue, setEditValue] = useState('')
const [envError, setEnvError] = useState('')
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const handleError = useCallback((err) => {
if (!alive.current) return
if (err?.status === 409 && err.message === NOT_ACTIVE) {
setInactive(true)
return
}
setError(err?.message || 'Unbekannter Fehler')
}, [])
const load = useCallback(async () => {
try {
const r = await fetchPreview(projectId)
if (!alive.current) return
setData(r)
setInactive(false)
setError('')
} catch (err) {
handleError(err)
} finally {
if (alive.current) setLoading(false)
}
}, [projectId, handleError])
useEffect(() => { load() }, [load])
// Solange gebaut/gestartet wird: alle 5 s nachladen
const state = data?.state
useEffect(() => {
if (state !== 'building' && state !== 'starting') return undefined
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [state, load])
const run = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
await load()
} catch (err) {
handleError(err)
} finally {
if (alive.current) setBusy(false)
}
}
const openLog = (runFile) => {
setLogFile(runFile)
setLogText('')
if (!runFile) return
setBusy(true)
setError('')
fetchPreviewLog(projectId, runFile)
.then((r) => {
if (alive.current) setLogText(Array.isArray(r.lines) ? r.lines.join('\n') : String(r.log ?? r.text ?? ''))
})
.catch((err) => {
handleError(err)
if (alive.current) setLogText(`Fehler: ${err?.message || 'Protokoll konnte nicht geladen werden'}`)
})
.finally(() => { if (alive.current) setBusy(false) })
}
const addPending = (name, value) => {
setPendingSet((p) => ({ ...p, [name]: value }))
setPendingRemove((r) => r.filter((x) => x !== name))
}
const handleAdd = () => {
if (!isValidEnvName(newName)) {
setEnvError('Name nur mit A–Z, 0–9 und _ (nicht mit einer Ziffer beginnen).')
return
}
if (!newValue) {
setEnvError('Bitte einen Wert eingeben.')
return
}
if (envValueTooLong(newValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
if (!names.includes(newName) && names.length >= MAX_ENV_VARS) {
setEnvError(`Maximal ${MAX_ENV_VARS} Variablen möglich.`)
return
}
setEnvError('')
addPending(newName, newValue)
setNewName('')
setNewValue('')
}
const handleRemove = (name) => {
setPendingSet((p) => {
const { [name]: _drop, ...rest } = p
return rest
})
if ((data?.envNames || []).includes(name)) setPendingRemove((r) => [...new Set([...r, name])])
}
const handleChange = () => {
if (!editValue) {
setEnvError('Bitte einen neuen Wert eingeben.')
return
}
if (envValueTooLong(editValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
setEnvError('')
addPending(editName, editValue)
setEditName('')
setEditValue('')
}
const handleSaveEnv = () => run(async () => {
await savePreviewEnv(projectId, buildEnvPayload(pendingSet, pendingRemove))
setPendingSet({})
setPendingRemove([])
})
const enter = (fn) => (e) => {
if (e.key === 'Enter') {
e.preventDefault()
fn()
}
}
if (loading) return <div className="text-grey" style={{ fontSize: 12 }}>Lädt …</div>
const view = data ? { ...data } : { state: 'off' }
const status = previewStatus(view)
const mode = data?.mode || 'auto'
const hint = modeHint(mode, data?.detected)
const names = [...new Set([...(data?.envNames || []), ...Object.keys(pendingSet)])].filter((n) => !pendingRemove.includes(n))
const dirty = Object.keys(pendingSet).length > 0 || pendingRemove.length > 0
const dev = isDevPreview(data)
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
const small = { fontSize: 12 }
return (
<div style={{ fontSize: 13 }}>
{data && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
<select
id={`pv-mode-${projectId}`}
className="form-control"
style={{ maxWidth: 200 }}
value={mode}
disabled={busy || inactive}
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
>
{modeOptionsFor(data.devAvailable).map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
</select>
</div>
{data.devAvailable === false && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{NO_DEV_HINT}</div>}
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dotColor(STATE_OF_COLOR[status.color]), display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
</div>
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
<div style={{ color: '#f87171', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
)}
</>
)}
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
{error && <div style={{ color: '#f87171', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{data && !inactive && (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
Neu bauen
</button>
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
Protokoll ansehen
</button>
{dev && (
<>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
Jetzt starten
</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
Stoppen
</button>
</>
)}
</div>
{showLogs && (
<div style={{ marginTop: 8 }}>
<select
className="form-control"
style={{ maxWidth: '100%' }}
aria-label="Lauf wählen"
value={logFile}
disabled={busy}
onChange={(e) => openLog(e.target.value)}
>
<option value="">Lauf wählen …</option>
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && (
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'rgba(0,0,0,0.35)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logText || '…'}
</pre>
)}
</div>
)}
<div style={{ marginTop: 14 }}>
<strong style={small}>Variablen</strong>
<div style={{ marginTop: 6 }}>
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
{names.map((n) => (
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
<span className="text-grey">•••</span>
{editName === n ? (
<>
<input className="form-control" style={{ flex: '1 1 120px', minWidth: 0 }} type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="password" autoComplete="off" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
</div>
{envError && <div style={{ color: '#f87171', ...small, marginTop: 4 }}>{envError}</div>}
{dirty && (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
</div>
)}
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
</div>
</div>
</>
)}
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { FaEye } from 'react-icons/fa6'
import { createPreviewLink } from '../lib/teamApi'
import { openInNewTab } from '../lib/openInNewTab'
// Mit subdomain holt der Knopf einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
// ohne subdomain bleibt er ein einfacher Link.
export default function PreviewLinkButton({ href, subdomain, title = 'Preview oeffnen', size = 16 }) {
if (!href) return null
const open = (event) => {
if (!subdomain) return
event.preventDefault()
openInNewTab(() => createPreviewLink({ subdomain, previewUrl: href })).catch((err) =>
window.alert(err.message || 'Die Preview konnte nicht geöffnet werden.')
)
}
return (
<a
href={href}
onClick={open}
target="_blank"
rel="noopener noreferrer"
title={title}
aria-label={title}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: 'rgba(16, 185, 129, 0.15)',
border: '1px solid rgba(16, 185, 129, 0.35)',
color: '#34d399',
textDecoration: 'none',
flexShrink: 0,
}}
>
<FaEye size={size} />
</a>
)
}
-36
View File
@@ -1,36 +0,0 @@
const PRIORITIES = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Critical' }
]
const PRIORITY_LABELS = {
0: 'NONE',
1: 'LOW',
2: 'MEDIUM',
3: 'HIGH',
4: 'CRITICAL'
}
export default function PriorityDropdown({ value, onChange }) {
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{PRIORITY_LABELS[value] || 'LOW'}
</button>
<div className="dropdown-content">
{PRIORITIES.map(priority => (
<span
key={priority.value}
className="dropdown-item"
onClick={() => onChange(priority.value)}
>
{priority.label}
</span>
))}
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { IconFolders } from '@tabler/icons-react'
import { StatusPill } from './ui/StatusPill'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
/**
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugewiesene Projekt
* und alle anderen Tickets desselben Kunden (klickbar).
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
*/
export default function ProjectContextPanel({ ticket }) {
const { fetchByTicketId } = useWebsiteProjects()
const [context, setContext] = useState(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const assignedProjects = await fetchByTicketId(ticket.$id)
if (!assignedProjects.length) {
if (!cancelled) setContext(null)
return
}
const projectName = assignedProjects[0].projectName || ''
let otherTickets = []
if (ticket.customerId) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', ticket.customerId),
Query.orderDesc('$createdAt'),
Query.limit(50),
])
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
}
if (!cancelled) setContext({ projectName, otherTickets })
} catch {
if (!cancelled) setContext(null)
}
}
load()
return () => { cancelled = true }
}, [ticket.$id, ticket.customerId, fetchByTicketId])
if (!context) return null
const { projectName, otherTickets } = context
return (
<div className="ui-card pad" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<IconFolders size={18} /> Projekt: {projectName || '-'}
</h5>
</div>
{!ticket.customerId ? (
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
</p>
) : !otherTickets.length ? (
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Keine weiteren Tickets zu diesem Kunden/Projekt.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{otherTickets.map((t) => (
<Link
key={t.$id}
to={`/tickets?woid=${t.woid}`}
style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: 'var(--text)', textDecoration: 'none',
padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border)',
}}
>
<span className="muted" style={{ minWidth: 48, fontWeight: 700 }}>{t.woid}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.topic || t.title || '-'}
</span>
<StatusPill status={t.status} />
</Link>
))}
</div>
)}
</div>
)
}
-89
View File
@@ -1,89 +0,0 @@
import { FaTimes } from 'react-icons/fa'
import { format } from 'date-fns'
const STATUS_CLASSES = {
'Open': 'bg-red',
'Occupied': 'bg-blue-grey',
'Assigned': 'bg-teal',
'Awaiting': 'bg-amber',
'Closed': 'bg-grey'
}
const PRIORITY_CLASSES = {
0: 'bg-blue',
1: 'bg-green',
2: 'bg-amber',
3: 'bg-orange',
4: 'bg-red'
}
const PRIORITY_LABELS = {
0: 'NONE',
1: 'LOW',
2: 'MEDIUM',
3: 'HIGH',
4: 'CRITICAL'
}
export default function QuickOverviewModal({ isOpen, onClose, workorders = [] }) {
if (!isOpen) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h3 className="mb-2">WOMS Ticket Quick Overview</h3>
<table className="table" style={{ background: '#fff', color: '#000' }}>
<thead>
<tr className="bg-dark-grey text-white">
<th>WOID</th>
<th>Created</th>
<th>Type</th>
<th>System</th>
<th>CID & CLO</th>
<th>Requested by</th>
<th>Topic</th>
<th className="text-center">Priority</th>
<th className="text-right">Status</th>
<th>Editor</th>
</tr>
</thead>
<tbody>
{workorders.map(wo => (
<tr key={wo.$id} className="bg-white">
<td className="bg-dark-grey text-white text-small">
{wo.woid || wo.$id?.slice(-5)}
</td>
<td className="bg-light-grey text-small">
{format(new Date(wo.$createdAt || wo.createdAt), 'dd.MM.yyyy, HH:mm')}h
</td>
<td className="bg-light-grey text-small">{wo.type}</td>
<td className="bg-light-grey text-small">{wo.systemType || 'n/a'}</td>
<td className="text-small">{wo.customerCode} {wo.customerLocation}</td>
<td className="text-small">{wo.requestedBy}</td>
<td className="text-small">{wo.topic}</td>
<td className={`text-small text-center ${PRIORITY_CLASSES[wo.priority]}`}>
{PRIORITY_LABELS[wo.priority]}
</td>
<td className={`text-small text-right ${STATUS_CLASSES[wo.status]}`}>
{wo.status?.toUpperCase()}
</td>
<td className={`text-small ${STATUS_CLASSES[wo.status]}`}>
{wo.assignedTo || '-'}
</td>
</tr>
))}
</tbody>
</table>
<p className="mt-2 text-center">
Summary: Listed a total of {workorders.length} Workorders!
<br />EOL =)
</p>
</div>
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
const RESPONSE_LEVELS = [
{ value: 'KEY USER', label: 'Key User' },
{ value: 'Helpdesk', label: '1st Level' },
{ value: 'Support', label: '2nd Level' },
{ value: 'Admin', label: '3rd Level' },
{ value: 'FS/FE', label: 'FS/FE' },
{ value: '24/7', label: '24/7' },
{ value: 'TECH MGMT', label: 'Tech Mgmt' },
{ value: 'Backoffice', label: 'Backoffice' },
{ value: 'BUSI MGMT', label: 'Busi Mgmt' }
]
export default function ResponseDropdown({ value, onChange }) {
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{value?.toUpperCase() || 'BACKOFFICE'}
</button>
<div className="dropdown-content">
{RESPONSE_LEVELS.map(level => (
<span
key={level.value}
className="dropdown-item"
onClick={() => onChange(level.value)}
>
{level.label}
</span>
))}
</div>
</div>
)
}
+7 -18
View File
@@ -1,30 +1,19 @@
const STATUSES = [
'Open',
'Occupied',
'Assigned',
'Awaiting',
'Added Info',
'In Test',
'Halted',
'Aborted',
'Cancelled',
'Closed'
]
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
export default function StatusDropdown({ value, onChange }) {
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{value?.toUpperCase() || 'OPEN'}
{statusLabel(value)}
</button>
<div className="dropdown-content">
{STATUSES.map(status => (
<span
key={status}
{TICKET_STATUSES.map(status => (
<span
key={status.value}
className="dropdown-item"
onClick={() => onChange(status)}
onClick={() => onChange(status.value)}
>
{status}
{status.label}
</span>
))}
</div>
-143
View File
@@ -1,143 +0,0 @@
import { FaTimes } from 'react-icons/fa'
import { format } from 'date-fns'
export default function StatusHistoryModal({ isOpen, onClose, worksheets, ticket }) {
if (!isOpen) return null
// Extrahiere Status-Änderungen aus Worksheets
const statusHistory = worksheets
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
.map(ws => ({
date: ws.startDate,
time: ws.startTime,
from: ws.oldStatus,
to: ws.newStatus,
employee: ws.employeeName || ws.employeeShort || 'Unknown',
details: ws.details,
wsid: ws.wsid
}))
.sort((a, b) => {
// Sortiere nach Datum und Zeit (älteste zuerst)
const dateA = `${a.date} ${a.time || '0000'}`
const dateB = `${b.date} ${b.time || '0000'}`
return dateA.localeCompare(dateB)
})
// Füge aktuellen Status hinzu
const currentStatus = ticket?.status || 'Open'
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Status History - WOID {ticket?.woid || ticket?.$id}</h2>
<div style={{ marginBottom: '24px' }}>
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
marginBottom: '16px'
}}>
<strong style={{ color: 'var(--green-primary)' }}>Current Status:</strong>
<span style={{
marginLeft: '12px',
padding: '4px 12px',
borderRadius: '4px',
background: 'rgba(16, 185, 129, 0.2)',
color: 'var(--dark-text)',
fontWeight: 'bold'
}}>
{currentStatus}
</span>
</div>
</div>
{statusHistory.length === 0 ? (
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
color: '#a0aec0'
}}>
No status changes recorded yet.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{statusHistory.map((entry, index) => (
<div
key={index}
style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderLeft: '4px solid var(--green-primary)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '8px' }}>
<div>
<div style={{ fontSize: '14px', color: '#a0aec0', marginBottom: '4px' }}>
{entry.date} {entry.time ? `${entry.time.substring(0, 2)}:${entry.time.substring(2, 4)}` : ''}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
background: 'rgba(74, 85, 104, 0.5)',
color: 'var(--dark-text)',
fontSize: '12px'
}}>
{entry.from}
</span>
<span style={{ color: 'var(--green-primary)' }}>→</span>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
background: 'rgba(16, 185, 129, 0.3)',
color: 'var(--dark-text)',
fontSize: '12px',
fontWeight: 'bold'
}}>
{entry.to}
</span>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', color: '#a0aec0' }}>by</div>
<div style={{ fontWeight: 'bold', color: 'var(--dark-text)' }}>{entry.employee}</div>
{entry.wsid && (
<div style={{ fontSize: '11px', color: '#718096', marginTop: '4px' }}>
WSID: {entry.wsid}
</div>
)}
</div>
</div>
{entry.details && (
<div style={{
marginTop: '12px',
padding: '12px',
background: 'rgba(31, 41, 55, 0.6)',
borderRadius: '6px',
fontSize: '13px',
color: '#cbd5e0',
borderLeft: '3px solid rgba(16, 185, 129, 0.4)'
}}>
{entry.details}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
/* Haken "Unterstuetzung" mit Zeilen Mitarbeiter + "Wobei?". Nur fuer die Person, die das
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
laedt erst hier, damit nicht jede Ticket-Karte sie abruft. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes }) {
const { employees, loading, error } = useEmployees()
const [enabled, setEnabled] = useState(value.length > 0)
const perPerson = stepLabel(supportMinutes(minutes))
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
const toggle = (checked) => {
setEnabled(checked)
onChange(checked ? [{ employeeId: '', name: '', task: '' }] : [])
}
const update = (index, patch) => onChange(value.map((row, i) => (i === index ? { ...row, ...patch } : row)))
const pick = (index, userId) => {
const employee = choices.find((e) => e.userId === userId)
update(index, { employeeId: userId, name: employee?.displayName || '' })
}
const remove = (index) => {
const next = value.filter((_, i) => i !== index)
onChange(next)
if (!next.length) setEnabled(false)
}
return (
<div className="form-group supporters-editor">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
Unterstützung
</label>
{enabled && (
<div style={{ marginTop: 8 }}>
{!loading && (error || !choices.length) && (
<p className="text-grey" style={{ fontSize: 13 }}>Mitarbeiterliste konnte nicht geladen werden.</p>
)}
{value.map((row, index) => (
<div key={index} className="flex gap-2" style={{ marginBottom: 8, alignItems: 'center' }}>
<select
className="form-control"
aria-label="Mitarbeiter"
value={row.employeeId}
onChange={(e) => pick(index, e.target.value)}
>
<option value="">Person wählen …</option>
{choices
.filter((e) => e.userId === row.employeeId || !value.some((r) => r.employeeId === e.userId))
.map((e) => <option key={e.userId} value={e.userId}>{e.displayName}</option>)}
</select>
<input
className="form-control"
aria-label="Wobei?"
placeholder="Wobei?"
maxLength={200}
value={row.task}
onChange={(e) => update(index, { task: e.target.value })}
/>
<span className="text-grey" style={{ whiteSpace: 'nowrap', fontSize: 13 }}>= {perPerson}</span>
<button type="button" className="btn btn-sm" aria-label="Zeile entfernen" onClick={() => remove(index)}>
<FaTimes />
</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
)}
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useState } from 'react'
import { FaChevronDown, FaPlay, FaStop, FaPlus } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
import UrgentToggle from './UrgentToggle'
import EditorDropdown from './EditorDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import OwnSupportModal from './OwnSupportModal'
import WorksheetSummary from './WorksheetSummary'
import TicketProjects from './TicketProjects'
import ProjectContextPanel from './ProjectContextPanel'
import Tabs from './ui/Tabs'
import Badge from './ui/Badge'
import { StatusPill, UrgentPill } from './ui/StatusPill'
import { useWorksheets } from '../hooks/useWorksheets'
import { useCustomerAbo } from '../hooks/useCustomerAbo'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { canEditWorksheetTimes } from '../lib/ticketForm'
import { canAddOwnSupport } from '../lib/support'
import { ticketTypeLabel } from '../lib/ticketStatus'
// Projekt / Preview steht mit im Details-Reiter
const TABS = [
{ id: 'details', label: 'Details' },
{ id: 'worksheets', label: 'Arbeitsblätter' },
]
export default function TicketCard({ ticket, onUpdate }) {
const { user, employee } = useAuth()
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblaetter gebraucht werden
const { hoursAllowed: aboHoursAllowed } = useCustomerAbo(
ticket.customerId,
(expanded && activeTab === 'worksheets') || showCreateWorksheet
)
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, saveOwnSupport, getTotalTime } = useWorksheets(
expanded ? ticket.woid : null
)
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de, addSuffix: true })
const stop = (e) => e.stopPropagation()
// Abo-Stunden: Arbeitszeit vom Stundenguthaben des Wartungs-Abos abziehen oder nicht
const handleTogglePlan = (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser)
if (result.success && worksheetData.newStatus !== ticket.status) {
await onUpdate(ticket.$id, { status: worksheetData.newStatus })
}
// Akquise-Automatik: Ergebnis im Worksheet steuert den Kundenstatus
if (result.success && ticket.type === 'Akquise' && ticket.customerId) {
const outcome = worksheetData.newStatus
const next = outcome === 'Zugesagt' ? 'customer' : outcome === 'Abgesagt' ? 'lost' : null
if (next) {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId, {
customerStatus: next,
updatedAt: new Date().toISOString(),
})
} catch { /* nicht kritisch */ }
}
}
return result
}
return (
<div className={`tcard ${expanded ? 'open' : ''}`}>
<div className="tcard-main" onClick={() => setExpanded((e) => !e)}>
<div className="tcard-woid">
<span className="id">{ticket.woid || ticket.$id?.slice(-5)}</span>
<span className="age">{elapsed}</span>
</div>
<div className="tcard-body">
<div className="tcard-row1">
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
{ticket.customerLocation && <span className="tcard-loc">· {ticket.customerLocation}</span>}
</div>
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
<div className="tcard-badges">
<StatusPill status={ticket.status} />
<UrgentPill priority={ticket.priority} />
{ticket.type && <Badge tone="muted">{ticketTypeLabel(ticket.type)}</Badge>}
</div>
</div>
<div className="tcard-side" onClick={stop}>
<div style={{ minWidth: 120 }}>
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
</div>
<UrgentToggle value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
</div>
</div>
{expanded && (
<div className="tcard-detail">
<div className="flex gap-3 wrap" style={{ marginBottom: 16, fontSize: 13 }}>
<span className="muted"><FaPlay style={{ color: 'var(--ok)' }} /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}</span>
<span className="muted"><FaStop style={{ color: 'var(--danger)' }} /> {ticket.deadline || '-'}</span>
{ticket.requestedBy && <span className="muted">Angefragt von: {ticket.requestedBy}</span>}
<span className="grow" />
<div style={{ minWidth: 150 }}><EditorDropdown value={ticket.assignedTo} onChange={(v) => onUpdate(ticket.$id, { assignedTo: v })} /></div>
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="ticket-tab-content">
{activeTab === 'details' && (
<div>
<div className="ui-card pad" style={{ marginBottom: 16 }}>
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
</div>
<ProjectContextPanel ticket={ticket} />
<TicketProjects ticket={ticket} />
</div>
)}
{activeTab === 'worksheets' && (
<div>
<div className="flex gap-2" style={{ marginBottom: 16 }}>
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
<FaPlus /> Arbeitsblatt hinzufügen
</button>
</div>
<WorksheetSummary worksheets={worksheets} />
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}
onOwnSupport={setOwnSupportWorksheet}
currentUserId={user?.$id}
onTogglePlan={handleTogglePlan}
aboHoursAllowed={aboHoursAllowed}
/>
</div>
)}
</div>
</div>
)}
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} aboHoursAllowed={aboHoursAllowed} />
{ownSupportWorksheet && (
<OwnSupportModal
worksheet={ownSupportWorksheet}
currentUserId={user?.$id}
onClose={() => setOwnSupportWorksheet(null)}
onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)}
/>
)}
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
/>
)}
</div>
)
}
-177
View File
@@ -1,177 +0,0 @@
import { useState } from 'react'
import { FaCaretDown } from 'react-icons/fa6'
const TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
]
const SYSTEMS = [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
]
const STATUSES = [
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed'
]
const PRIORITIES = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
{ value: 2, label: 'Medium' },
{ value: 3, label: 'High' },
{ value: 4, label: 'Critical' }
]
export default function TicketFilters({ filters, onChange, onApply }) {
const [localFilters, setLocalFilters] = useState(filters)
const handleCheckboxChange = (category, value) => {
const current = localFilters[category] || []
const updated = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
setLocalFilters({ ...localFilters, [category]: updated })
}
const handleInputChange = (field, value) => {
setLocalFilters({ ...localFilters, [field]: value })
}
const handleApply = () => {
onChange(localFilters)
onApply()
}
return (
<tr className="bg-dark-grey text-white">
<td className="text-center">
<input
type="text"
placeholder="WOID"
className="form-control"
style={{ width: '80px', background: '#616161', color: '#fff', border: 'none' }}
value={localFilters.woid || ''}
onChange={(e) => handleInputChange('woid', e.target.value)}
/>
</td>
<td>
<input
type="text"
placeholder="Created"
className="form-control"
style={{ width: '100px', background: '#616161', color: '#fff', border: 'none' }}
value={localFilters.createdDate || ''}
onChange={(e) => handleInputChange('createdDate', e.target.value)}
/>
</td>
<td>
<div className="dropdown">
<button className="btn btn-dark">
Type / Location <FaCaretDown />
</button>
<div className="dropdown-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{TICKET_TYPES.map(type => (
<label key={type} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={(localFilters.type || []).includes(type)}
onChange={() => handleCheckboxChange('type', type)}
/>
{type}
</label>
))}
</div>
</div>
</td>
<td>
<div className="dropdown">
<button className="btn btn-dark">
System <FaCaretDown />
</button>
<div className="dropdown-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{SYSTEMS.map(system => (
<label key={system} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={(localFilters.system || []).includes(system)}
onChange={() => handleCheckboxChange('system', system)}
/>
{system}
</label>
))}
</div>
</div>
</td>
<td>
<input
type="text"
placeholder="Customer or Location"
className="form-control"
style={{ width: '150px', background: '#616161', color: '#fff', border: 'none' }}
value={localFilters.customer || ''}
onChange={(e) => handleInputChange('customer', e.target.value)}
/>
</td>
<td>
<input
type="text"
placeholder="User or Topic"
className="form-control"
style={{ width: '150px', background: '#616161', color: '#fff', border: 'none' }}
value={localFilters.userTopic || ''}
onChange={(e) => handleInputChange('userTopic', e.target.value)}
/>
</td>
<td>
<div className="dropdown">
<button className="btn btn-dark">
Status <FaCaretDown />
</button>
<div className="dropdown-content">
{STATUSES.map(status => (
<label key={status} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={(localFilters.status || []).includes(status)}
onChange={() => handleCheckboxChange('status', status)}
/>
{status}
</label>
))}
</div>
</div>
</td>
<td>
<div className="dropdown">
<button className="btn btn-dark">
Priority <FaCaretDown />
</button>
<div className="dropdown-content">
{PRIORITIES.map(p => (
<label key={p.value} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={(localFilters.priority || []).includes(p.value)}
onChange={() => handleCheckboxChange('priority', p.value)}
/>
{p.label}
</label>
))}
</div>
</div>
</td>
<td className="text-center">Approval</td>
<td className="text-center">
<button className="btn btn-green" onClick={handleApply}>
Apply!
</button>
</td>
</tr>
)
}
+229
View File
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
import { slugify } from '../lib/subdomain'
import AssignedProjectCard from './AssignedProjectCard'
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
import { kindLabelFor } from '../lib/assignFlow'
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
export default function TicketProjects({ ticket }) {
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selected, setSelected] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [importing, setImporting] = useState(false)
const [importHint, setImportHint] = useState('')
const [assignResult, setAssignResult] = useState(null)
const [kinds, setKinds] = useState([])
const [kindDialog, setKindDialog] = useState(false)
useEffect(() => {
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {})
}, [])
const load = useCallback(async () => {
if (!ticket?.$id) return
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
setAssigned(mine)
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
}, [ticket, fetchAllProjects, fetchByTicketId])
useEffect(() => {
let cancelled = false
;(async () => {
setImporting(true)
setImportHint('')
try {
await importNewReposThrottled()
} catch (err) {
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
} finally {
if (!cancelled) setImporting(false)
}
if (!cancelled) await load()
})()
return () => { cancelled = true }
}, [load])
const run = async (action) => {
setBusy(true)
setError('')
try {
await action()
} catch (err) {
setError(err.message || 'Aktion fehlgeschlagen')
} finally {
// Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
setBusy(false)
}
}
const assign = () => {
const project = available.find((p) => p.$id === selected)
// Ohne Art erst "Was ist das?" fragen
if (project && !project.kind) {
setKindDialog(true)
return
}
assignPlain()
}
const assignPlain = async () => {
setBusy(true)
setError('')
setAssignResult(null)
try {
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
const one = r.results[0]
setAssignResult(assignSummary(projectName, one.ok ? one.result : { error: one.error }))
if (r.success) setSelected('')
await load()
} catch (err) {
setError(err.message || 'Aktion fehlgeschlagen')
} finally {
setBusy(false)
}
}
// Art gewaehlt: completeAssignment weist serverseitig (wie assignProject) zu,
// daher kein zusaetzliches assignProjects.
// Bei 409 (bereits zugeordnet) bewusst NICHT auf assignProjects zurueckfallen:
// das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden.
const assignWithKind = async (kind) => {
setKindDialog(false)
setError('')
setAssignResult(null)
if (!ticket.customerId) {
setError('Dieses Ticket hat keinen Kunden. Bitte zuerst einen Kunden am Ticket eintragen.')
return
}
setBusy(true)
try {
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
const r = await completeAssignment(selected, {
kind,
forCustomer: true,
customerId: ticket.customerId,
ticketId: ticket.$id,
})
setAssignResult({ text: `${projectName || 'Projekt'} wurde zugewiesen.`, warnings: r.warnings || [] })
setSelected('')
} catch (err) {
setError(err.status === 409
? `${err.message} Die Liste wurde neu geladen.`
: err.message || 'Aktion fehlgeschlagen')
} finally {
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
setBusy(false)
}
}
const unassign = (projectId) => run(async () => {
const r = await unassignProject(projectId)
if (!r.success) throw new Error(r.error)
})
const create = (e) => {
e.preventDefault()
if (!slugify(createForm.subdomain)) {
setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).')
return
}
run(async () => {
const subdomain = slugify(createForm.subdomain)
await createProjectFromTemplate({
repoName: subdomain,
displayName: createForm.displayName,
subdomain,
customerId: ticket.customerId || '',
ticketId: ticket.$id,
// Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt")
kind: 'website',
})
setCreateForm({ displayName: '', subdomain: '' })
setShowCreate(false)
})
}
return (
<div className="ui-card pad ticket-projects">
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => (
<div key={p.$id}>
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
<AssignedProjectCard
project={p}
showActions
onUnassign={unassign}
customerName={ticket.customerName || ''}
onChanged={load}
/>
</div>
))}
<div className="ticket-projects-assign">
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">Projekt wählen …</option>
{available.map((p) => (
<option key={p.$id} value={p.$id}>
{p.projectName} ({p.subdomain || p.repoFullName})
{isOutsideOrg(p.repoFullName) ? ' – wird beim Zuweisen nach WEBklar verschoben' : ''}
</option>
))}
</select>
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
</div>
{assignResult && (
<div style={{ marginTop: 8, fontSize: 13 }}>
<div>{assignResult.text}</div>
{assignResult.warnings.map((w, i) => (
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div>
))}
</div>
)}
{showCreate && (
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
</form>
)}
{kindDialog && (
<div className="overlay">
<span className="overlay-close" onClick={() => setKindDialog(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Was ist das?</h2>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
{kinds.length === 0 && (
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{kinds.map((k) => (
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
))}
</div>
</div>
</div>
)}
</div>
)
}
-355
View File
@@ -1,355 +0,0 @@
import { useState } from 'react'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
import PriorityDropdown from './PriorityDropdown'
import EditorDropdown from './EditorDropdown'
import ResponseDropdown from './ResponseDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = {
0: 'priority-none',
1: 'priority-low',
2: 'priority-medium',
3: 'priority-high',
4: 'priority-critical'
}
const PRIORITY_LABELS = {
0: 'NONE',
1: 'LOW',
2: 'MEDIUM',
3: 'HIGH',
4: 'CRITICAL'
}
const STATUS_CLASSES = {
'Open': 'status-open',
'Occupied': 'status-occupied',
'Assigned': 'status-assigned',
'Awaiting': 'status-awaiting',
'Closed': 'status-closed'
}
const APPROVAL_ICONS = {
'pending': FaUserGear,
'approved': FaSackDollar,
'quote': FaSackDollar,
'shipping': FaTruck
}
export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true)
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
// Worksheets für dieses Ticket laden (nur wenn expanded)
const {
worksheets,
loading: worksheetsLoading,
createWorksheet,
getTotalTime
} = useWorksheets(expanded ? ticket.woid : null)
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de })
const handleStatusChange = (newStatus) => {
onUpdate(ticket.$id, { status: newStatus })
}
const handlePriorityChange = (newPriority) => {
onUpdate(ticket.$id, { priority: newPriority })
}
const handleEditorChange = (newEditor) => {
onUpdate(ticket.$id, { assignedTo: newEditor })
}
const handleResponseChange = (newResponse) => {
onUpdate(ticket.$id, { responseLevel: newResponse })
}
const toggleLock = () => {
if (locked) {
setExpanded(true)
onExpand?.(ticket.$id)
} else {
setExpanded(false)
}
setLocked(!locked)
}
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser)
// Wenn Status geändert wurde, aktualisiere Work Order
if (result.success && worksheetData.newStatus !== ticket.status) {
await onUpdate(ticket.$id, {
status: worksheetData.newStatus,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
})
}
return result
}
const ApprovalIcon = APPROVAL_ICONS[ticket.approvalStatus] || FaUserGear
return (
<>
<tr className={`ticket-row ${expanded ? 'ticket-expanded' : 'ticket-collapsed'}`}>
<td className={`ticket-id ${expanded ? 'ticket-id-expanded' : ''}`} rowSpan={2}>
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
<div className="ticket-time">{elapsed}</div>
</td>
<td className="ticket-info" rowSpan={2}>
<strong>{format(createdAt, 'dd.MM.yyyy')}</strong>
</td>
<td className="ticket-info" rowSpan={2}>
<strong>{ticket.type}</strong>
<br />
{ticket.serviceType || 'Remote'}
<br />
<FaPlay className="text-green" /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}
<br />
<FaStop className="text-red" /> {ticket.deadline || '-'}
</td>
<td className="ticket-info" rowSpan={2}>
<strong>{ticket.systemType || 'n/a'}</strong>
</td>
<td rowSpan={2}>
<strong>{ticket.customerName || 'Unknown'}</strong>
<br />
{ticket.customerLocation || ''}
</td>
<td rowSpan={2}>
<strong>{ticket.requestedBy || '-'}</strong>
<br />
{ticket.requestedFor && <>Requested for: {ticket.requestedFor}<br /></>}
{ticket.topic}
</td>
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<StatusDropdown
value={ticket.status}
onChange={handleStatusChange}
/>
</td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<PriorityDropdown
value={ticket.priority}
onChange={handlePriorityChange}
/>
</td>
<td
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
rowSpan={2}
style={{ verticalAlign: 'middle' }}
>
<ApprovalIcon size={24} />
</td>
<td
className="bg-dark-grey text-center text-white"
rowSpan={2}
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
onClick={toggleLock}
>
{locked ? <FaLock size={24} /> : <FaLockOpen size={24} />}
</td>
</tr>
<tr className="ticket-row">
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<EditorDropdown
value={ticket.assignedTo}
onChange={handleEditorChange}
/>
</td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<ResponseDropdown
value={ticket.responseLevel}
onChange={handleResponseChange}
/>
</td>
</tr>
{expanded && (
<>
<tr className="worksheet-expansion">
<td colSpan={10} className="worksheet-cell">
<div className="card" style={{
borderRadius: '0 0 12px 12px',
marginTop: 0,
border: '1px solid rgba(16, 185, 129, 0.2)',
borderTop: 'none'
}}>
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
{/* Bento Box Layout: 2 Spalten */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
alignItems: 'stretch'
}}>
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '20px',
border: '1px solid rgba(16, 185, 129, 0.2)',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}>
<h5 style={{
color: 'var(--dark-text)',
fontWeight: 'bold',
marginBottom: '16px',
fontSize: '18px',
flex: '0 0 auto'
}}>
📋 Ticket-Beschreibung
</h5>
<p style={{
fontSize: '14px',
lineHeight: '1.8',
color: 'rgba(226, 232, 240, 0.8)',
whiteSpace: 'pre-wrap',
margin: 0,
flex: '1 1 auto',
overflowY: 'auto'
}}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
</div>
{/* Rechte Spalte: Statistics, Buttons (50%) */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
height: '100%'
}}>
{/* Button Row: Add Worksheet (100%) + History Icon Button */}
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'stretch'
}}>
{/* Add Worksheet Button - 100% width minus icon button */}
<button
className="btn"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
border: 'none',
padding: '12px 20px',
borderRadius: '8px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
onClick={() => setShowCreateWorksheet(true)}
>
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
</button>
{/* History Icon Button - klein, grau, nur Icon */}
<button
style={{
background: '#616161',
color: 'white',
border: 'none',
padding: '12px',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '44px',
width: '44px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#757575'
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#616161'
e.currentTarget.style.transform = 'translateY(0)'
}}
onClick={() => setShowHistoryModal(true)}
title="Status History"
>
<FaClockRotateLeft size={18} />
</button>
</div>
{/* Statistiken */}
{worksheets.length > 0 && (
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0
}}>
<WorksheetStats worksheets={worksheets} />
</div>
)}
</div>
</div>
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
<div style={{
marginTop: '20px',
width: '100%'
}}>
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
/>
</div>
</div>
</div>
</td>
</tr>
</>
)}
<CreateWorksheetModal
isOpen={showCreateWorksheet}
onClose={() => setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
/>
<StatusHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
worksheets={worksheets}
ticket={ticket}
/>
<tr className="spacer">
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
</tr>
</>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { isUrgent, priorityFor } from '../lib/ticketStatus'
// Ein Klick markiert ein Ticket als dringend oder wieder als normal
export default function UrgentToggle({ value, onChange }) {
const urgent = isUrgent(value)
return (
<button
type="button"
className={`urgent-toggle${urgent ? ' on' : ''}`}
aria-pressed={urgent}
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
onClick={() => onChange(priorityFor(!urgent))}
>
Dringend
</button>
)
}
+135 -36
View File
@@ -1,13 +1,37 @@
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa'
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
import { statusLabel } from '../lib/ticketStatus'
import { parseSupporters, supportMinutes, firstName } from '../lib/support'
export default function WorksheetList({ worksheets, totalTime, loading }) {
// Kopffarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen
const TONES = {
'git-open': { border: '#f97316', base: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)', hover: 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)' },
'git-done': { border: '#10b981', base: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', hover: 'linear-gradient(135deg, #059669 0%, #047857 100%)' },
comment: { border: '#3b82f6', base: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', hover: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' },
work: { border: '#4a5568', base: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)', hover: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)' },
}
// Hinweise im farbigen Kopf: mit der grauen Badge-Schrift waeren sie kaum lesbar
const headerBadgeStyle = { background: 'rgba(255,255,255,0.3)', color: 'white', marginLeft: '8px' }
const headerButtonStyle = {
background: 'rgba(255,255,255,0.25)',
color: 'white',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '6px',
padding: '2px 10px',
fontSize: '0.8rem',
marginRight: '8px',
}
export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes, canAddSupport, onOwnSupport, currentUserId }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
<div className="text-center p-4">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
<span className="visually-hidden">Lädt …</span>
</div>
</div>
)
@@ -17,7 +41,7 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
return (
<div className="alert alert-info" role="alert">
<FaComment className="me-2" />
Noch keine Worksheets vorhanden. Erstelle das erste Worksheet für dieses Ticket.
Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an.
</div>
)
}
@@ -49,13 +73,17 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
<div className="timeline">
{worksheets.map((ws, index) => {
const isExpanded = expandedWorksheets[ws.wsid] || false
const isGit = ws.serviceType === 'GIT'
const tone = worksheetTone(ws)
const colors = TONES[tone]
const supporters = ws.isComment ? [] : parseSupporters(ws.supporters)
return (
<div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}>
<div className="card border-0 shadow-sm overflow-hidden" style={{
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
borderLeft: `4px solid ${colors.border}`,
borderRadius: '8px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
@@ -70,9 +98,7 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
className="card-header d-flex justify-content-between align-items-center py-3"
onClick={() => toggleWorksheet(ws.wsid)}
style={{
background: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
background: colors.base,
color: 'white',
border: 'none',
cursor: 'pointer',
@@ -80,14 +106,10 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = ws.isComment
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
e.currentTarget.style.background = colors.hover
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
e.currentTarget.style.background = colors.base
}}
>
<div className="d-flex align-items-center gap-3">
@@ -98,13 +120,31 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
)}
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
{ws.isComment && !isGit && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaComment className="me-1" /> Kommentar
</span>
)}
{tone === 'git-open' && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaClock className="me-1" /> Zeit offen
</span>
)}
{tone === 'git-done' && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaCheck className="me-1" /> Zeit eingetragen
</span>
)}
{supporters.length > 0 && (
<span className="badge ms-2" title="Weitere Personen haben unterstützt" style={headerBadgeStyle}>
+{supporters.length} Unterstützung
</span>
)}
{ws.countsToPlan && !ws.isComment && (
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{ ...headerBadgeStyle, background: '#9d3dfe' }}>
Abo-Stunden
</span>
)}
</div>
{/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && (
@@ -119,16 +159,74 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
<span style={{ fontSize: '0.9rem' }}>{formatTime(ws.totalTime)}</span>
</div>
)}
<span className="badge" style={{
<span className="badge" style={{
background: 'rgba(255,255,255,0.2)',
color: 'white',
fontSize: '0.8rem'
}}>{ws.serviceType}</span>
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
)}
</div>
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime)}
</small>
<div className="d-flex align-items-center gap-2">
{onEditTimes && canEditTimes?.(ws) && (
<button
type="button"
className="btn btn-sm"
title="Beginn, Ende und Arbeitszeit ändern"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onEditTimes(ws)
}}
>
{needsTimeEntry(ws)
? <><FaClock className="me-1" /> Zeit nachtragen</>
: <><FaPen className="me-1" /> Zeiten bearbeiten</>}
</button>
)}
{onOwnSupport && canAddSupport?.(ws) && (
<button
type="button"
className="btn btn-sm"
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onOwnSupport(ws)
}}
>
<FaHandsHelping className="me-1" />{' '}
{parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId)
? 'Meine Unterstützung bearbeiten'
: 'Ich habe unterstützt'}
</button>
)}
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan) && (
<button
type="button"
className="btn btn-sm"
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
style={{
background: ws.countsToPlan ? '#9d3dfe' : 'rgba(255,255,255,0.15)',
color: 'white',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '6px',
padding: '2px 10px',
fontSize: '0.8rem',
marginRight: '8px',
}}
onClick={(e) => {
e.stopPropagation()
onTogglePlan(ws)
}}
>
{ws.countsToPlan ? '✓ Abo' : '+ Abo'}
</button>
)}
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
</small>
</div>
</div>
{/* Body - Nur wenn expanded */}
@@ -163,13 +261,24 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
</div>
</div>
{/* Unterstuetzung: je Person die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten */}
{supporters.length > 0 && (
<div className="mb-3 worksheet-supporters">
{supporters.map((sup) => (
<div key={sup.employeeId} style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
↳ Unterstützung {firstName(sup.name)} · {sup.task} · {formatTime(supportMinutes(ws.totalTime))}
</div>
))}
</div>
)}
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{ws.serviceType}</span>
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
{/* Status-Änderung */}
@@ -179,19 +288,9 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
}}>
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{statusLabel(ws.oldStatus)}</span>
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
</div>
)}
{/* Response Level-Änderung */}
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
<div className="mb-3">
<span className="text-muted">Response Level:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
<span className="mx-2">→</span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{statusLabel(ws.newStatus)}</span>
</div>
)}
-312
View File
@@ -1,312 +0,0 @@
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
export default function WorksheetStats({ worksheets }) {
if (!worksheets || worksheets.length === 0) {
return null
}
// Gesamtarbeitszeit
const totalMinutes = worksheets
.filter(ws => !ws.isComment)
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
// Nach Mitarbeiter gruppieren
const byEmployee = worksheets.reduce((acc, ws) => {
const empId = ws.employeeId
if (!acc[empId]) {
acc[empId] = {
name: ws.employeeName,
short: ws.employeeShort,
time: 0,
count: 0
}
}
if (!ws.isComment) {
acc[empId].time += ws.totalTime || 0
}
acc[empId].count += 1
return acc
}, {})
// Service Type Verteilung
const byServiceType = worksheets.reduce((acc, ws) => {
const type = ws.serviceType || 'Unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
const formatTime = (minutes) => {
if (!minutes || minutes === 0) return '0min'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
return (
<div className="worksheet-stats" style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
height: '100%'
}}>
{/* Gesamtübersicht */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '0 0 auto'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FaChartLine size={16} style={{ color: '#10b981' }} />
Gesamtübersicht
</h6>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Worksheets:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.length}</strong>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Arbeitszeit:</span>
<strong style={{ color: '#10b981', fontSize: '16px' }}>{formatTime(totalMinutes)}</strong>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Kommentare:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.filter(ws => ws.isComment).length}</strong>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Ø pro WS:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
</strong>
</div>
</div>
</div>
{/* Mitarbeiter Kombiniertes Diagramm */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '1 1 auto',
minHeight: '200px'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '16px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FaUsers size={16} style={{ color: '#10b981' }} />
Mitarbeiter-Statistiken
</h6>
{Object.keys(byEmployee).length === 0 ? (
<div style={{ color: '#a0aec0', textAlign: 'center', padding: '20px' }}>
Keine Mitarbeiter-Daten verfügbar
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{(() => {
const employeeArray = Object.values(byEmployee).sort((a, b) => b.time - a.time)
const maxTime = Math.max(...employeeArray.map(e => e.time), 1)
return employeeArray.map((emp, idx) => {
const percentage = maxTime > 0 ? (emp.time / maxTime) * 100 : 0
return (
<div key={idx}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '6px',
fontSize: '11px'
}}>
<span style={{
color: 'var(--dark-text)',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: '120px'
}}>
{emp.short && (
<span style={{
background: 'rgba(16, 185, 129, 0.2)',
color: '#10b981',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '9px',
fontWeight: 'bold'
}}>{emp.short}</span>
)}
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{emp.name}
</span>
</span>
</div>
<div style={{
width: '100%',
height: '28px',
background: 'rgba(26, 32, 44, 0.6)',
borderRadius: '6px',
overflow: 'hidden',
position: 'relative',
display: 'flex',
alignItems: 'center'
}}>
{/* WS Anzahl am Anfang */}
<div style={{
position: 'absolute',
left: '8px',
zIndex: 2,
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<span>WS</span>
<span style={{
background: 'rgba(255, 255, 255, 0.3)',
padding: '2px 6px',
borderRadius: '4px'
}}>{emp.count}</span>
</div>
{/* Balken mit Zeit */}
<div style={{
width: `${percentage}%`,
height: '100%',
background: 'linear-gradient(90deg, #10b981 0%, #059669 100%)',
borderRadius: '6px',
transition: 'width 0.5s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
paddingRight: '8px',
paddingLeft: '60px',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
position: 'relative'
}}>
{/* Zeit am Ende des Balkens */}
<span style={{
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
{formatTime(emp.time)}
</span>
</div>
{/* Zeit außerhalb des Balkens (falls Balken zu kurz) */}
{percentage < 30 && (
<div style={{
position: 'absolute',
right: '8px',
zIndex: 2,
color: '#10b981',
fontSize: '11px',
fontWeight: 'bold'
}}>
{formatTime(emp.time)}
</div>
)}
</div>
</div>
)
})
})()}
</div>
)}
</div>
{/* Service Type Verteilung */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '0 0 auto'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FaClock size={16} style={{ color: '#10b981' }} />
Service Types
</h6>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{Object.entries(byServiceType).map(([type, count]) => (
<div key={type} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{
color: 'var(--dark-text)',
fontSize: '12px',
fontWeight: '500'
}}>{type}</span>
<strong style={{
color: '#10b981',
fontSize: '14px'
}}>{count}</strong>
</div>
))}
</div>
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { needsTimeEntry } from '../lib/ticketForm'
import { worksheetMinutes } from '../lib/support'
const formatTime = (minutes) => {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
// Eine Zeile ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), Anzahl, offene Git-Zeiten
export default function WorksheetSummary({ worksheets }) {
if (!worksheets || worksheets.length === 0) return null
const minutes = worksheets
.filter((ws) => !ws.isComment)
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
const open = worksheets.filter(needsTimeEntry).length
return (
<div className="worksheet-summary">
<span>Arbeitszeit gesamt <strong>{formatTime(minutes)}</strong></span>
<span>{worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'}</span>
{open > 0 && <span className="open">{open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}</span>}
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
import { DateTimeInput, DurationSelect } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
const formFromWorksheet = (worksheet) => ({
startDate: germanDateToIso(worksheet.startDate || worksheet.endDate),
startTime: womsTimeToInput(worksheet.startTime),
endDate: germanDateToIso(worksheet.endDate || worksheet.startDate),
endTime: womsTimeToInput(worksheet.endTime)
})
/**
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
*/
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave }) {
const isGit = isGitWorksheet(worksheet)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
// schon vorher nicht dazu, wurde sie von Hand gewaehlt (z.B. ohne Pause) und bleibt stehen.
// Alte Zeiten ausserhalb des Rasters (z.B. 70) werden auf den naechsten Schritt aufgerundet.
const [manualTotal, setManualTotal] = useState(() => {
const stored = Number(worksheet.totalTime) || 0
const calculated = minutesBetween(formData.startTime, formData.endTime)
if (!stored || (calculated !== null && stored === roundUpToStep(calculated))) return null
return isValidStep(stored) ? stored : roundUpToStep(stored)
})
const [supporters, setSupporters] = useState(() => parseSupporters(worksheet.supporters))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
const totalTime = manualTotal ?? calculatedTotal ?? 0
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
const handleStartDateChange = (value) => {
setFormData(prev => ({
...prev,
startDate: value,
endDate: value && prev.endDate && prev.endDate < value ? value : prev.endDate
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!isValidStep(totalTime)) {
setError('Bitte die Arbeitszeit auswählen.')
return
}
const supportError = validateSupporters(supporters, currentUserId)
if (supportError) {
setError(supportError)
return
}
setSaving(true)
setError('')
try {
const result = await onSave(worksheet, {
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
endDate: isoToGermanDate(formData.endDate),
endTime: timeToWoms(formData.endTime),
totalTime: Number(totalTime),
supporters,
supportersBefore: worksheet.supporters
})
if (result?.success) {
onClose()
return
}
setError(result?.error || 'Speichern fehlgeschlagen')
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
}
setSaving(false)
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 560 }}>
{/* Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X */}
<h2 className="mb-2">{needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid}
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-start">Beginn</label>
<DateTimeInput
id="worksheet-times-start"
timeLabel="Uhrzeit Beginn"
required
timeRequired={isGit}
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
<DateTimeInput
id="worksheet-times-end"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-times-total"
value={totalTime}
onChange={(value) => setManualTotal(value)}
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{manualTotal === null && calculatedTotal !== null
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
</small>
</div>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
/>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotateRight } from 'react-icons/fa6'
import { Button } from '../ui'
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
import { loadChatComposer } from '../../lib/loadChatComposer'
import { messageMeta, needsFullRefresh, noticeText } from '../../lib/chatFormat'
import '../../styles/chat.css'
const POLL_MS = 15 * 1000
/**
* Gespraech mit einem Kunden (Messenger und Reiter "Chat"). Sprechblasen und Eingabezeile kommen aus dem
* Chat-Baustein des Portals (window.ChatComposer); React verwaltet nur die leeren Container.
* Antworten fuer einen inzwischen gewechselten Kunden werden verworfen (alive je Effekt).
*/
export default function ChatThread({ customerId, customerName }) {
const zoneRef = useRef(null)
const boxRef = useRef(null)
const composerRef = useRef(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState('')
const [reloadKey, setReloadKey] = useState(0)
// Name nur ueber eine Referenz lesen: kommt er spaeter (Liste/Kunden laden noch), startet das Gespraech nicht neu
const nameRef = useRef(customerName)
nameRef.current = customerName
useEffect(() => {
let alive = true
let composer = null
let timer = null
let chat = null
const state = { shown: new Set(), last: '', fullAt: 0 }
setError(null)
setNotice('')
const append = (messages) => {
const box = boxRef.current
if (!alive || !box || !chat) return
let added = false
for (const msg of messages) {
if (state.shown.has(msg.id)) continue
state.shown.add(msg.id)
box.appendChild(chat.renderMessage(msg, {
mine: msg.sender === 'admin',
meta: messageMeta(msg, nameRef.current),
fileUrl: (att) => att.url,
}))
if (msg.createdAt > state.last) state.last = msg.createdAt
added = true
}
if (added) box.scrollTop = box.scrollHeight
}
// Ganzer Verlauf mit frischen signierten Links (beim Oeffnen und nach 10 Minuten)
const loadAll = async () => {
const { messages } = await getChatMessages(customerId)
if (!alive) return
boxRef.current?.replaceChildren()
state.shown = new Set()
state.last = ''
state.fullAt = Date.now()
append(messages || [])
markChatRead(customerId).catch(() => {})
}
const poll = async () => {
if (!alive || document.visibilityState === 'hidden' || !chat) return
try {
if (needsFullRefresh(state.fullAt, Date.now())) return await loadAll()
const { messages } = await getChatMessages(customerId, state.last)
if (!alive) return
append(messages || [])
if ((messages || []).some((m) => m.sender === 'customer')) markChatRead(customerId).catch(() => {})
} catch {
/* naechste Abfrage versucht es erneut */
}
}
;(async () => {
try {
chat = await loadChatComposer({ baseUrl: PORTAL_URL })
if (!alive) return
chat.trackScroll(boxRef.current)
composer = chat.create({
root: composerRef.current,
dropZone: zoneRef.current,
uploadUrl: chatUploadUrl(customerId),
requestHeaders: teamAuthHeaders,
placeholder: `Antwort an ${nameRef.current || 'Kunde'}…`,
placeholderShort: 'Antwort…',
send: async ({ text, attachmentIds }) => {
const { message, notice: result } = await sendChatMessage(customerId, { text, attachmentIds })
const time = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
if (alive) setNotice(noticeText(result, time))
return message
},
onSent: (message) => append([message]),
})
await loadAll()
// waehrend des Ladens verlassen: keinen Timer mehr anlegen, den das Aufraeumen nicht mehr sieht
if (!alive) return
timer = setInterval(poll, POLL_MS)
} catch (err) {
if (alive) setError(err.message || 'Die Unterhaltung konnte nicht geladen werden.')
}
})()
const onVisible = () => { if (document.visibilityState === 'visible') poll() }
document.addEventListener('visibilitychange', onVisible)
return () => {
alive = false
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
composer?.destroy()
}
}, [customerId, reloadKey])
return (
<div className="woms-chat" ref={zoneRef}>
{error && (
<div className="flex items-center gap-2 wrap">
<span className="muted">{error}</span>
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
</div>
)}
<div className="chat-messages" ref={boxRef} aria-live="polite" />
{notice && <div className="chat-notice muted">{notice}</div>}
<div ref={composerRef} />
</div>
)
}
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import KundenansichtButton from '../customer/KundenansichtButton'
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
import { applyMessage, BILLING_MONTHS, calcLine, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import '../../styles/admin-areas.css'
const CHECK_TONE = { ok: 'ok', warn: 'warn', info: 'info' }
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
const toCents = (value) => Math.round(Number(value) * 100)
/**
* Antrag auf Uebernahmegutschrift: Pruefung und Entscheidung wie im frueheren Admin-Panel.
* Waehrend einer Entscheidung sind alle Knoepfe gesperrt (busyRef verhindert auch Doppelklicks vor dem Neuzeichnen).
*/
export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
const [d, setD] = useState(null)
const [error, setError] = useState(null)
const [message, setMessage] = useState('')
const [form, setForm] = useState(null)
const [calcText, setCalcText] = useState('')
const [actionError, setActionError] = useState('')
const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
const amountTouched = useRef(false)
const calcTimer = useRef(null)
const load = useCallback(async (msg = '') => {
setError(null)
try {
const detail = await getCredit(id)
const r = detail.request
setD(detail)
setMessage(msg)
setActionError('')
amountTouched.current = false
setForm({
checks: Object.fromEntries(detail.requiredChecks.map((k) => [k.key, false])),
invoiceAmount: (r.invoiceAmountCents / 100).toFixed(2),
billingMonths: r.billingMonths,
paidUntil: r.paidUntilDate || '',
takeoverFrom: r.takeoverFromDate || '',
amount: ((detail.calc?.suggestedCents || 0) / 100).toFixed(2),
note: '',
})
setCalcText(detail.calc ? calcLine(detail.calc) : 'Angaben unvollständig')
} catch (err) {
setError(err.message || 'Antrag konnte nicht geladen werden.')
}
}, [id])
useEffect(() => {
setD(null)
load()
return () => clearTimeout(calcTimer.current)
}, [load])
const figures = (f) => ({ invoiceAmountCents: toCents(f.invoiceAmount), billingMonths: Number(f.billingMonths), paidUntil: f.paidUntil, takeoverFrom: f.takeoverFrom })
const setFigure = (key, value) => {
const next = { ...form, [key]: value }
setForm(next)
clearTimeout(calcTimer.current)
calcTimer.current = setTimeout(async () => {
try {
const { calc } = await calcCredit(figures(next))
setCalcText(calcLine(calc))
if (!amountTouched.current) setForm((f) => ({ ...f, amount: (calc.suggestedCents / 100).toFixed(2) }))
} catch (err) {
setCalcText(err.message)
}
}, 300)
}
const decide = async (action) => {
if (busyRef.current) return
setActionError('')
const problem = decisionError(action, form.note)
if (problem) { setActionError(problem); return }
const amountCents = toCents(form.amount)
if (!window.confirm(decisionConfirm(action, amountCents, d.customer?.name))) return
if (busyRef.current) return
busyRef.current = true
setBusy(true)
try {
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
onChanged?.()
await load(decisionMessage(action, result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
}
}
const apply = async () => {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
try {
const result = await applyCredit(id)
onChanged?.()
await load(applyMessage(result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
}
}
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></Card>
if (!d || !form) return <Card><FaSpinner className="spinner" /></Card>
const r = d.request
const c = d.customer
const s = d.subscription
const st = creditStatus(r.status)
const pending = r.status === 'offen' || r.status === 'rueckfrage'
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const allChecked = Object.values(form.checks).every(Boolean)
const canApprove = allChecked && Number(form.amount) > 0 && !busy
return (
<Card>
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
</div>
<Button variant="ghost" onClick={onClose}>Schließen</Button>
</div>
{message && <div className="area-message">{message}</div>}
<div className="credit-cols">
<div>
<h4>Antrag</h4>
<dl className="area-kv">
<dt>Leistung</dt><dd>{r.services}</dd>
<dt>Bisher bei</dt><dd>{r.provider}</dd>
<dt>Domain</dt><dd>{r.domain ? <a href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>{r.email}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
</dl>
<h4>Grund / Nachricht</h4>
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
<h4>Nachweise</h4>
{r.files.length ? (
<ul className="credit-files">
{r.files.map((f) => (
<li key={f.id}>
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
</li>
))}
</ul>
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
<h4>Verlauf</h4>
{r.history.map((h, i) => (
<div key={i} className="credit-history">
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
</div>
))}
</div>
<div>
<h4>Kunde &amp; Vertrag</h4>
<dl className="area-kv">
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
<dt>Abo</dt>
<dd>
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><br /><span className="area-muted">Zusatzspeicher: {storageLabel(s.storageUnits)}</span></> : <Badge tone="danger">kein Abo</Badge>}
</dd>
{s && (
<>
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
</>
)}
</dl>
<div className="area-inline" style={{ margin: '12px 0' }}>
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
</div>
{d.otherRequests.length > 0 && (
<>
<h4>Weitere Anträge des Kunden</h4>
<ul className="credit-files">
{d.otherRequests.map((o) => {
const ost = creditStatus(o.status)
return (
<li key={o.id}>
<a href="#" onClick={(e) => { e.preventDefault(); onOpen?.(o.id) }}>{o.ref}</a> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
</li>
)
})}
</ul>
</>
)}
<h4>Automatische Prüfung</h4>
<ul className="credit-checks">
{d.autoChecks.map((k) => (
<li key={k.key}><Badge tone={CHECK_TONE[k.status] || 'muted'}>{k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'}</Badge> {k.label}{k.detail && <><br /><small className="area-muted">{k.detail}</small></>}</li>
))}
</ul>
{pending ? (
<>
<h4>Prüfung durch dich</h4>
<div className="credit-manual">
{d.requiredChecks.map((k) => (
<label key={k.key}>
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
</label>
))}
</div>
<h4>Berechnung</h4>
<div className="credit-calc">
<label>Rechnungsbetrag (€)<input id="calc-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} /></label>
<label>Rechnung gilt für
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</label>
<label>Bezahlt bis<input id="calc-paid-until" className="form-control" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} /></label>
<label>Übernahme ab<input id="calc-from" className="form-control" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} /></label>
</div>
<div className="area-summary">{calcText}</div>
<p className="area-muted">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
<label className="credit-field">Gutschrift (€)
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
</label>
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</label>
<div className="area-inline">
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen &amp; gutschreiben</Button>
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
{busy && <FaSpinner className="spinner" />}
</div>
<p className="area-muted">
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
</p>
</>
) : (
<>
<h4>Entscheidung</h4>
{r.status === 'genehmigt' ? (
<>
<dl className="area-kv">
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>{r.calculation}</dd>
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
{r.decisionNote && <><dt>Nachricht</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd></>}
<dt>Verrechnung</dt>
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="area-muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
</dl>
{!r.appliedAt && <div className="area-inline" style={{ marginTop: 12 }}><Button variant="primary" onClick={apply} disabled={busy}>Jetzt in Stripe gutschreiben</Button></div>}
</>
) : (
<dl className="area-kv">
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
</dl>
)}
</>
)}
{actionError && <div className="area-error">{actionError}</div>}
</div>
</div>
</Card>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { listCredits } from '../../lib/teamApi'
import { creditStatus } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents } from '../../lib/format'
import '../../styles/admin-areas.css'
// Liste der Uebernahmegutschriften (Filter "Offen"/"Alle"), wie im frueheren Admin-Panel; optional nur ein Kunde
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
const [filter, setFilter] = useState('open')
const [rows, setRows] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let alive = true
setError(null)
listCredits({ status: filter, customerId })
.then(({ requests }) => { if (alive) setRows(requests || []) })
.catch((err) => { if (alive) setError(err.message || 'Anträge konnten nicht geladen werden.') })
return () => { alive = false }
}, [filter, customerId, reloadKey])
return (
<div>
<div className="area-filter seg">
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen</button>
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
</div>
<Card pad={false}>
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
{!rows && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
{rows && (
<div className="area-rows">
<div className="area-row credit-row head"><span>Eingang</span><span>Kunde</span><span>Leistung / bisher bei</span><span>Rechnung</span><span>Bezahlt bis</span><span>Status</span><span /></div>
{rows.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</span></div>}
{rows.map((r) => {
const st = creditStatus(r.status)
return (
<div key={r.id} className={`area-row credit-row ${r.id === selectedId ? 'active' : ''}`}>
<span>{fmtDateTime(r.createdAt)}<br /><span className="area-muted">{r.ref}</span></span>
<span><strong>{r.customerName}</strong>{r.customerCode && <><br /><span className="area-muted">Kd.-Nr. {r.customerCode}</span></>}</span>
<span>{r.services}<br /><span className="area-muted">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</span></span>
<span>{formatCents(r.invoiceAmountCents)}<br /><span className="area-muted">für {r.billingMonths} Mon.</span></span>
<span>{fmtDate(r.paidUntil)}</span>
<span>
<Badge tone={st.tone}>{st.label}</Badge>
{r.status === 'genehmigt' && <><br /><span className="area-muted">{formatCents(r.amountCents)}{r.appliedAt ? '' : ' · nicht gebucht'}</span></>}
</span>
<span>
<Button size="sm" variant="primary" onClick={() => onSelect(r.id)}>
{['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
</Button>
</span>
</div>
)
})}
</div>
)}
</Card>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import CreditList from './CreditList'
import CreditDetail from './CreditDetail'
/**
* Gutschriften: Liste und Antrag (Adresse ?antrag=<id>, andere Parameter wie ?tab= bleiben). Sammelliste unter
* Kunden -> Gutschriften und Reiter "Gutschriften" auf der Kundenseite (mit customerId).
*/
export default function CreditsView({ customerId }) {
const [params, setParams] = useSearchParams()
const selected = params.get('antrag') || ''
const [reloadKey, setReloadKey] = useState(0)
const select = (id) =>
setParams((prev) => {
const next = new URLSearchParams(prev)
if (id) next.set('antrag', id)
else next.delete('antrag')
return next
})
return (
<div className="credits-view">
{selected && (
<div style={{ marginBottom: 16 }}>
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
</div>
)}
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
</div>
)
}
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more