Compare commits

50 Commits
Author SHA1 Message Date
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
105 changed files with 10853 additions and 2474 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
}
+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`
+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": "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 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": "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 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
+1180 -18
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -12,6 +12,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0"
+31 -71
View File
@@ -1,49 +1,62 @@
import { BrowserRouter, Routes, Route, Navigate } 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 RoadmapsPage from './pages/RoadmapsPage'
import RoadmapDetailPage from './pages/RoadmapDetailPage'
import ReportsPage from './pages/ReportsPage'
import DocsPage from './pages/DocsPage'
import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage'
import LeadsPage from './pages/LeadsPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage'
import DocumentsPage from './pages/DocumentsPage'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
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 children
}
function AppRoutes() {
const { user } = useAuth()
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
<Route path="/roadmaps/:id" element={<ProtectedRoute><RoadmapDetailPage /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
@@ -53,74 +66,21 @@ function AppRoutes() {
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>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
import ProjectReadme from './ProjectReadme'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
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 }) {
const lastPush = project.lastPushAt || project.giteaSyncedAt
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>
)}
{project.liveDomain && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<FaGlobe style={{ marginRight: 4, color: '#34d399' }} />
<a href={`https://${project.liveDomain}`} target="_blank" rel="noopener noreferrer" style={{ color: '#34d399', textDecoration: 'none' }}>
{project.liveDomain}
</a>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<PreviewLinkButton href={project.previewUrl} />
<GiteaLinkButton href={project.giteaRepoUrl} />
{showActions && onUnassign && (
<button type="button" className="btn btn-sm" onClick={() => onUnassign(project.$id)}><FaTrash /></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} />
{project.hasPreview && <StatusBadge label="Preview" value="aktiv" />}
</div>
{/* Letzter Commit */}
{(project.lastCommitMessage || project.lastCommitSha) && (
<div style={{ marginTop: 8, fontSize: 12, color: '#94a3b8', background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: '8px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FaCodeCommit style={{ color: '#3b82f6' }} />
{project.lastCommitSha && <code style={{ color: '#cbd5e0' }}>{String(project.lastCommitSha).slice(0, 7)}</code>}
{project.lastCommitBranch || project.lastPushBranch ? (
<span className="text-grey">({project.lastPushBranch || project.lastCommitBranch})</span>
) : null}
</div>
{project.lastCommitMessage && (
<div style={{ marginTop: 4, color: '#cbd5e0', whiteSpace: 'pre-wrap' }}>{project.lastCommitMessage}</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{project.lastCommitAuthor && <span>von {project.lastCommitAuthor}</span>}
{lastPush && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<FaClock /> {new Date(lastPush).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
)}
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
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 || ''
}
+122 -212
View File
@@ -1,30 +1,25 @@
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, isDemoMode } from '../lib/appwrite'
import {
SERVICE_TYPES,
todayIso,
isoToGermanDate,
timeToWoms,
buildRequesterSuggestions,
rankSuggestions,
} from '../lib/ticketForm'
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' },
@@ -33,58 +28,59 @@ const DEFAULT_PRIORITIES = [
{ value: 4, label: 'Critical' }
]
const today = new Date().toLocaleDateString('de-DE')
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder)
const emptyForm = () => ({
customerId: '',
type: TICKET_TYPES[0].value,
serviceType: SERVICE_TYPES[0].value,
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: todayIso(),
startTime: '',
deadline: todayIso(),
endTime: '',
estimate: '30',
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 || isDemoMode) 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 +88,35 @@ 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)
try {
const result = await onCreate(formData)
const result = await onCreate({
...formData,
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')
}
@@ -140,13 +137,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New 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">
@@ -173,36 +170,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type} value={type}>{type}</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>
</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>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
@@ -215,7 +183,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('serviceType', e.target.value)}
>
{SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
@@ -235,30 +203,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</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>
@@ -276,70 +227,51 @@ 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">Requested by</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"
<label className="form-label" htmlFor="ticket-requested-for">Requested for</label>
<AutocompleteInput
id="ticket-requested-for"
placeholder="Name"
value={formData.requestedFor}
onChange={(e) => handleChange('requestedFor', e.target.value)}
onChange={(value) => handleChange('requestedFor', value)}
getSuggestions={getSuggestions}
/>
</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>
<label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label>
<DateTimeInput
id="ticket-start-date"
timeLabel="Start time"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</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>
<label className="form-label" htmlFor="ticket-end-date">End Date & Time (Deadline)</label>
<DateTimeInput
id="ticket-end-date"
timeLabel="End time"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
@@ -352,28 +284,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('estimate', e.target.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>
@@ -389,8 +299,8 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</div>
<div className="text-center mt-2">
<button
type="submit"
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
+106 -146
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAuth } from '../context/AuthContext'
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
import { DateTimeInput } from './ui'
const STATUS_OPTIONS = [
'Open',
@@ -17,39 +17,36 @@ const STATUS_OPTIONS = [
'In Test'
]
const RESPONSE_LEVELS = [
'KEY USER',
'1st Level',
'2nd Level',
'3rd Level',
'FS/FE',
'24/7',
'TECH MGMT',
'Backoffice',
'BUSI MGMT',
'n/a'
]
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
// 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
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
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 : STATUS_OPTIONS
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,63 +55,44 @@ 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
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: 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')
@@ -128,20 +106,19 @@ 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,
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
endDate: isoToGermanDate(formData.endDate),
endTime: timeToWoms(formData.endTime),
details: formData.details,
isComment: formData.isComment,
countsToPlan: !formData.isComment && formData.countsToPlan,
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
}
const result = await onCreate(worksheetData, user)
if (result.success) {
onClose()
} else {
@@ -164,13 +141,13 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New Worksheet - WOID {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">
@@ -183,37 +160,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' : 'New Status'}</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{STATUS_OPTIONS.map(status => (
{statusOptions.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>
))}
</select>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
@@ -226,75 +194,67 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
<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" htmlFor="worksheet-start-date">Start Date & Time</label>
<DateTimeInput
id="worksheet-start-date"
timeLabel="Start time"
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">End Date & Time</label>
<DateTimeInput
id="worksheet-end-date"
timeLabel="End time"
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">Total Time (Minuten)</label>
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
<input
type="number"
className="form-control"
min="0"
step="15"
step="1"
value={formData.totalTime}
onChange={(e) => handleChange('totalTime', e.target.value)}
disabled={formData.isComment}
placeholder="0"
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
</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>
@@ -311,8 +271,8 @@ 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}
>
+158
View File
@@ -0,0 +1,158 @@
import { useState, useEffect, useCallback } from 'react'
import { FaFileLines, FaDownload, FaEye, FaSpinner, FaRotate, FaUpload, FaMagnifyingGlass } from 'react-icons/fa6'
import { integrationsApi } from '../lib/integrationsApi'
import FileUploadModal from './FileUploadModal'
const card = {
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
}
export default function DocumentsPanel({ ticket, initialScope = 'ticket' }) {
const [docs, setDocs] = useState([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [query, setQuery] = useState('')
const [scope, setScope] = useState(initialScope) // 'ticket' | 'customer'
const [showUpload, setShowUpload] = useState(false)
const [busyId, setBusyId] = useState(null)
const correspondent = ticket.customerName || ''
const woid = ticket.woid || ''
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params = { page_size: 30 }
if (query) params.query = query
if (scope === 'customer' && correspondent) params.correspondent = correspondent
if (scope === 'ticket' && woid) params.tag = `WOID-${woid}`
const data = await integrationsApi.listDocuments(params)
setDocs(data.documents || [])
setCount(data.count || 0)
} catch (err) {
setError(err.message)
setDocs([])
} finally {
setLoading(false)
}
}, [query, scope, correspondent, woid])
useEffect(() => {
load()
}, [load])
const open = async (id, kind) => {
setBusyId(id + kind)
try {
await integrationsApi.openDocument(id, kind)
} catch (err) {
alert('Dokument konnte nicht geoeffnet werden: ' + err.message)
} finally {
setBusyId(null)
}
}
return (
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap', gap: '8px' }}>
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
Dokumente (Paperless)
</h5>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-green" style={{ margin: 0 }} onClick={() => setShowUpload(true)}>
<FaUpload /> Hochladen
</button>
<button className="btn" onClick={load} title="Aktualisieren"
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
<FaRotate />
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button
className={`btn ${scope === 'ticket' ? 'btn-green' : ''}`}
style={{ margin: 0, background: scope === 'ticket' ? undefined : '#374151', color: 'white' }}
onClick={() => setScope('ticket')}
>
Dieses Ticket
</button>
<button
className={`btn ${scope === 'customer' ? 'btn-green' : ''}`}
style={{ margin: 0, background: scope === 'customer' ? undefined : '#374151', color: 'white' }}
onClick={() => setScope('customer')}
disabled={!correspondent}
>
Ganzer Kunde
</button>
</div>
<div style={{ display: 'flex', gap: '6px', flex: 1, minWidth: '180px' }}>
<input
className="form-control"
style={{ margin: 0 }}
placeholder="Volltextsuche..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && load()}
/>
<button className="btn btn-green" style={{ margin: 0 }} onClick={load}>
<FaMagnifyingGlass />
</button>
</div>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{loading ? (
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Dokumente werden geladen...</p>
) : docs.length === 0 ? (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>
Keine Dokumente gefunden{scope === 'ticket' ? ' fuer dieses Ticket' : correspondent ? ` fuer ${correspondent}` : ''}.
</p>
) : (
<>
<p style={{ color: '#718096', fontSize: '12px', marginTop: 0 }}>{count} Dokument(e)</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{docs.map((d) => (
<div key={d.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: 'rgba(0,0,0,0.15)', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
<FaFileLines style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--dark-text)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
<div style={{ color: '#718096', fontSize: '12px' }}>{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
<button className="btn" title="Vorschau" disabled={busyId === d.id + 'preview'}
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => open(d.id, 'preview')}>
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
</button>
<button className="btn" title="Download" disabled={busyId === d.id + 'download'}
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => open(d.id, 'download')}>
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
</button>
</div>
</div>
))}
</div>
</>
)}
<FileUploadModal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
workorderId={woid}
ticket={ticket}
onUploadComplete={() => setTimeout(load, 1500)}
/>
</div>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useMemo, useState } from 'react'
import { FaClock } from 'react-icons/fa'
/** Minuten zwischen hhmm-Zeiten, Übernacht (+24h) wie useWorksheets.calculateTime. */
function calculateMinutes(startTime, endTime) {
if (!startTime || !endTime) return null
const sh = parseInt(startTime.substring(0, 2), 10)
const sm = parseInt(startTime.substring(2, 4), 10)
const eh = parseInt(endTime.substring(0, 2), 10)
const em = parseInt(endTime.substring(2, 4), 10)
if ([sh, sm, eh, em].some(Number.isNaN)) return null
let diff = eh * 60 + em - (sh * 60 + sm)
if (diff < 0) diff += 24 * 60
return diff
}
function formatMinutes(minutes) {
if (minutes === null || minutes === undefined) return '–'
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}min` : `${m}min`
}
/**
* Startzeit fuer Git-Push-Worksheets nachtragen: Die Endzeit wurde beim Push
* automatisch gesetzt, hier wird der Arbeitsbeginn erfasst. Beim Speichern
* wird totalTime berechnet und isComment auf false gesetzt, damit der Eintrag
* in der Arbeitszeit-Statistik zaehlt.
*/
export default function EditWorksheetTimeModal({ isOpen, onClose, worksheet, onSave }) {
const [startDate, setStartDate] = useState('')
const [startTime, setStartTime] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (isOpen && worksheet) {
setStartDate(worksheet.startDate || worksheet.endDate || '')
setStartTime(worksheet.startTime || '')
setError('')
}
}, [isOpen, worksheet])
const totalTime = useMemo(
() => calculateMinutes(startTime, worksheet?.endTime),
[startTime, worksheet]
)
if (!isOpen || !worksheet) return null
const handleSubmit = async (e) => {
e.preventDefault()
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(startDate)) {
setError('Startdatum bitte als TT.MM.JJJJ angeben.')
return
}
if (!/^\d{4}$/.test(startTime)) {
setError('Startzeit bitte als HHMM angeben (z.B. 0930).')
return
}
if (totalTime === null) {
setError('Zeit konnte nicht berechnet werden.')
return
}
setSaving(true)
setError('')
const result = await onSave(worksheet, {
startDate,
startTime,
totalTime,
isComment: false,
})
setSaving(false)
if (result?.success) {
onClose()
} else {
setError(result?.error || 'Speichern fehlgeschlagen')
}
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
×
</span>
<div className="overlay-content">
<h2 className="mb-2">
<FaClock style={{ marginRight: 8 }} />
Zeit nachtragen – WSID {worksheet.wsid}
</h2>
<p className="text-grey" style={{ fontSize: '14px' }}>
Die Endzeit wurde beim Git-Push automatisch gesetzt
({worksheet.endDate} {worksheet.endTime ? `${worksheet.endTime.substring(0, 2)}:${worksheet.endTime.substring(2, 4)}` : ''}).
Trage hier 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">Startdatum (TT.MM.JJJJ)</label>
<input
type="text"
className="form-control"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
placeholder="11.07.2026"
required
/>
</div>
<div className="form-group">
<label className="form-label">Startzeit (HHMM)</label>
<input
type="text"
className="form-control"
value={startTime}
onChange={(e) => setStartTime(e.target.value.replace(/[^0-9]/g, '').slice(0, 4))}
placeholder="0930"
maxLength={4}
required
/>
</div>
<div className="form-group">
<label className="form-label">Berechnete Arbeitszeit</label>
<div style={{ fontSize: '18px', fontWeight: 600, color: '#10b981' }}>
{formatMinutes(totalTime)}
</div>
</div>
<div className="text-center mt-2">
<button type="submit" className="btn btn-green" disabled={saving}>
{saving ? 'Speichert...' : 'Zeit speichern'}
</button>
</div>
</form>
</div>
</div>
)
}
+32 -4
View File
@@ -1,10 +1,12 @@
import { useState, useRef } from 'react'
import { FaTimes, FaSpinner } from 'react-icons/fa'
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
import { integrationsApi } from '../lib/integrationsApi'
export default function FileUploadModal({ isOpen, onClose, workorderId, onUploadComplete }) {
export default function FileUploadModal({ isOpen, onClose, workorderId, ticket, onUploadComplete }) {
const [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [status, setStatus] = useState('')
const fileInputRef = useRef(null)
const handleDrop = async (e) => {
@@ -33,14 +35,39 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
}
setUploading(true)
setStatus('Lade in Ablage hoch...')
try {
const response = await storage.createFile(
BUCKET_ID,
ID.unique(),
file
)
// Automatisch nach Paperless-ngx schieben (wichtige Kundendokumente)
const woid = ticket?.woid || workorderId
const correspondent = ticket?.customerName || undefined
if (correspondent || woid) {
setStatus('Synchronisiere mit Paperless...')
const tags = []
if (correspondent) tags.push(correspondent)
if (woid) tags.push(`WOID-${woid}`)
tags.push('Ticket')
try {
await integrationsApi.uploadToPaperless({
bucketId: BUCKET_ID,
fileId: response.$id,
filename: file.name,
title: `${woid ? `[${woid}] ` : ''}${file.name}`,
correspondent,
tags,
})
} catch (e) {
console.warn('Paperless-Sync fehlgeschlagen:', e.message)
// Upload in Appwrite ist trotzdem erfolgt - nicht hart abbrechen
}
}
onUploadComplete?.(response)
onClose()
} catch (error) {
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
alert('Error uploading file: ' + error.message)
} finally {
setUploading(false)
setStatus('')
}
}
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
{uploading ? (
<div>
<FaSpinner className="spinner" size={32} />
<p className="mt-1">Uploading file...</p>
<p className="mt-1">{status || 'Uploading file...'}</p>
</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>
)
}
+343
View File
@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react'
import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
import { useInvoices } from '../hooks/useInvoices'
import { customerStage, isInvoiceReady } from '../lib/leads'
const card = {
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
}
export default function InvoicePanel({ ticket }) {
const [customer, setCustomer] = useState(null)
const [customerLoading, setCustomerLoading] = useState(true)
const [customerError, setCustomerError] = useState(null)
const loadCustomer = useCallback(async () => {
if (!ticket.customerId) {
setCustomer(null)
setCustomerLoading(false)
return
}
setCustomerLoading(true)
try {
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId)
setCustomer(doc)
setCustomerError(null)
} catch (err) {
setCustomerError(err.message)
setCustomer(null)
} finally {
setCustomerLoading(false)
}
}, [ticket.customerId])
useEffect(() => {
loadCustomer()
}, [loadCustomer])
const clientId = customer?.invoiceNinjaClientId || null
const { invoices, loading, error, refresh, createInvoice } = useInvoices(clientId)
if (customerLoading) {
return (
<div style={card}>
<FaSpinner className="spinner" /> Kunde wird geladen...
</div>
)
}
if (!ticket.customerId) {
return (
<div style={card}>
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.</p>
</div>
)
}
return (
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
</h5>
{clientId && (
<button className="btn" onClick={refresh} title="Aktualisieren"
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
<FaRotate />
</button>
)}
</div>
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
{customer && customerStage(customer) === 'lead' ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollstaendige Daten noetig) - dann sind Rechnungen moeglich.
</p>
) : !clientId && !isInvoiceReady(customer) ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Rechnungsdaten unvollstaendig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Strasse, PLZ und Stadt ergaenzen.
</p>
) : !clientId ? (
<LinkClient customer={customer} onLinked={loadCustomer} />
) : (
<InvoiceList
ticket={ticket}
clientId={clientId}
invoices={invoices}
loading={loading}
error={error}
createInvoice={createInvoice}
/>
)}
</div>
)
}
// ---------------------------------------------------- Verknuepfung -----------
function LinkClient({ customer, onLinked }) {
const [search, setSearch] = useState(customer?.name || '')
const [results, setResults] = useState([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const doSearch = async () => {
setBusy(true)
setError(null)
try {
const data = await integrationsApi.listClients(search)
setResults(data.clients || [])
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
useEffect(() => {
doSearch()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const saveMapping = async (clientId) => {
setSaving(true)
setError(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
invoiceNinjaClientId: clientId,
})
onLinked()
} catch (err) {
setError(err.message)
setSaving(false)
}
}
const createAndLink = async () => {
setSaving(true)
setError(null)
try {
const data = await integrationsApi.createClient({
name: customer.name || customer.companyName || 'Neuer Kunde',
email: customer.email || '',
phone: customer.phone || '',
address: customer.location || '',
})
if (data.client?.id) {
await saveMapping(data.client.id)
} else {
throw new Error('Client konnte nicht angelegt werden')
}
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div>
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft.
</p>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input
className="form-control"
style={{ margin: 0 }}
placeholder="InvoiceNinja-Kunde suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
/>
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
</button>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
{results.map((c) => (
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div>
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
{c.number ? <span style={{ color: '#a0aec0' }}> � {c.number}</span> : null}
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
</div>
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
<FaLink /> Verknuepfen
</button>
</div>
))}
{!busy && results.length === 0 && (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
)}
</div>
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen &amp; verknuepfen</>}
</button>
</div>
)
}
// ---------------------------------------------------- Rechnungsliste ---------
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) {
const [showCreate, setShowCreate] = useState(false)
const [openingId, setOpeningId] = useState(null)
const openPdf = async (id) => {
setOpeningId(id)
try {
await integrationsApi.openInvoicePdf(id)
} catch (err) {
alert('PDF konnte nicht geoeffnet werden: ' + err.message)
} finally {
setOpeningId(null)
}
}
return (
<div>
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
<FaPlus /> Rechnung aus Ticket erstellen
</button>
{showCreate && (
<CreateInvoiceForm
ticket={ticket}
clientId={clientId}
onCancel={() => setShowCreate(false)}
onSubmit={async (payload) => {
const r = await createInvoice(payload)
if (r.success) setShowCreate(false)
return r
}}
/>
)}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{loading ? (
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
) : invoices.length === 0 ? (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen fuer diesen Kunden.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
<thead>
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
<th>Nr.</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '�', color: '#a0aec0' }
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
return (
<tr key={inv.id}>
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (ueberfaellig)' : ''}</span></td>
<td>
<button className="btn" title="PDF oeffnen" disabled={openingId === inv.id}
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => openPdf(inv.id)}>
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
// ---------------------------------------------------- Rechnung anlegen -------
function CreateInvoiceForm({ ticket, clientId, onCancel, onSubmit }) {
const [description, setDescription] = useState(ticket.topic || ticket.title || '')
const [cost, setCost] = useState('')
const [quantity, setQuantity] = useState('1')
const [markSent, setMarkSent] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError(null)
const r = await onSubmit({
client_id: clientId,
po_number: ticket.woid || '',
public_notes: `Ticket ${ticket.woid || ''}: ${ticket.topic || ''}`.trim(),
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
markSent,
})
if (!r.success) {
setError(r.error || 'Fehler beim Anlegen')
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
<div className="form-group">
<label className="form-label">Leistung / Beschreibung</label>
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">Einzelpreis (EUR)</label>
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">Menge</label>
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
Direkt als &quot;versendet&quot; markieren
</label>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', gap: '8px' }}>
<button type="submit" className="btn btn-green" disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
</button>
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
</div>
</form>
)
}
+94 -140
View File
@@ -1,174 +1,128 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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,
IconFiles,
IconFolders,
IconCalendar,
IconRoute,
IconLayoutDashboard,
IconReport,
IconDeviceDesktop,
IconSettings,
IconLogout
IconBook,
IconLogout,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
} from '@tabler/icons-react'
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
export default function Navbar() {
export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
const links = [
const groups = [
{
label: 'Tickets',
href: '/tickets',
icon: <IconTicket className="icon" />,
label: 'Betrieb',
links: [
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
],
},
{
label: 'Assets',
href: '/assets',
icon: <IconDeviceDesktop className="icon" />,
label: 'CRM',
links: [
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
],
},
{
label: 'Dashboard',
href: '/dashboard',
icon: <IconChartBar className="icon" />,
label: 'Finanzen',
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
},
{
label: 'Planboard',
href: '/planboard',
icon: <IconCalendar className="icon" />,
label: 'Dokumente',
links: [{ label: 'Dokumente', href: '/documents', icon: <IconFiles /> }],
},
{
label: 'Projects',
href: '/projects',
icon: <IconFolders className="icon" />,
label: 'Auswertung',
links: [
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
{ label: 'Reports', href: '/reports', icon: <IconReport /> },
],
},
{
label: 'Reports',
href: '/reports',
icon: <IconFileText className="icon" />,
},
{
label: 'Docs',
href: '/docs',
icon: <IconBook className="icon" />,
label: 'System',
links: [
{ label: 'Assets', href: '/assets', icon: <IconDeviceDesktop /> },
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
{ label: 'Docs', href: '/docs', icon: <IconBook /> },
],
},
]
// 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 + '/')
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} />
<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>
<nav className="side-nav">
{groups.map((group) => (
<div className="side-group" key={group.label}>
<div className="side-group-label">{group.label}</div>
{group.links.map((link) => (
<Link
key={link.href}
to={link.href}
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
<span className="side-link-label">{link.label}</span>
</Link>
))}
</div>
</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>
</>
) : (
<SidebarLink
link={{
label: 'Login',
href: '/login',
icon: <IconLogout className="icon" />,
}}
/>
)}
</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>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { FaEye } from 'react-icons/fa6'
export default function PreviewLinkButton({ href, title = 'Preview 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(16, 185, 129, 0.15)',
border: '1px solid rgba(16, 185, 129, 0.35)',
color: '#34d399',
textDecoration: 'none',
flexShrink: 0,
}}
>
<FaEye size={size} />
</a>
)
}
+97
View File
@@ -0,0 +1,97 @@
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 { fetchRoadmapByTicketId } from '../lib/roadmaps'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
/**
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugehoerige Projekt,
* alle anderen Tickets desselben Kunden (klickbar) und den Link zum aktiven Plan.
* Rendert nichts, wenn das Ticket keinen Projektbezug hat.
*/
export default function ProjectContextPanel({ ticket }) {
const { fetchByTicketId } = useWebsiteProjects()
const [context, setContext] = useState(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
// Projektbezug: Roadmap dieses Tickets oder direkt zugewiesenes Projekt
const [roadmap, assignedProjects] = await Promise.all([
fetchRoadmapByTicketId(ticket.$id).catch(() => null),
fetchByTicketId(ticket.$id),
])
const projectName = roadmap?.projectName || assignedProjects[0]?.projectName || ''
if (!roadmap && !assignedProjects.length) {
if (!cancelled) setContext(null)
return
}
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({ roadmap, projectName, otherTickets })
} catch {
if (!cancelled) setContext(null)
}
}
load()
return () => { cancelled = true }
}, [ticket.$id, ticket.customerId, fetchByTicketId])
if (!context) return null
const { roadmap, 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>
{roadmap && (
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">
Plan: {roadmap.title}
</Link>
)}
</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,
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>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useEffect } from 'react'
import { FaFileLines } from 'react-icons/fa6'
import ReactMarkdown from 'react-markdown'
import { fetchProjectReadme } from '../lib/projectAdminApi'
const markdownComponents = {
h1: ({ children }) => <h1 style={{ fontSize: '1.35rem', fontWeight: 700, margin: '0 0 10px', color: '#f8fafc' }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ fontSize: '1.15rem', fontWeight: 700, margin: '14px 0 8px', color: '#f1f5f9' }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '12px 0 6px', color: '#e2e8f0' }}>{children}</h3>,
p: ({ children }) => <p style={{ margin: '0 0 10px', lineHeight: 1.6 }}>{children}</p>,
ul: ({ children }) => <ul style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ol>,
code: ({ inline, children }) => inline ? (
<code style={{ background: 'rgba(30,41,59,0.9)', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em' }}>{children}</code>
) : <code>{children}</code>,
pre: ({ children }) => <pre style={{ background: 'rgba(15,23,42,0.95)', borderRadius: 6, padding: 10, overflowX: 'auto', margin: '0 0 10px' }}>{children}</pre>,
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>{children}</a>,
}
export default function ProjectReadme({ repoFullName }) {
const [state, setState] = useState({ loading: true, content: '', filename: '', error: '' })
useEffect(() => {
if (!repoFullName) { setState({ loading: false, content: '', filename: '', error: '' }); return }
let cancelled = false
setState({ loading: true, content: '', filename: '', error: '' })
fetchProjectReadme(repoFullName)
.then((data) => {
if (cancelled) return
if (!data.found) { setState({ loading: false, content: '', filename: '', error: '' }); return }
setState({ loading: false, content: data.content || '', filename: data.filename || 'README.md', error: '' })
})
.catch((err) => { if (!cancelled) setState({ loading: false, content: '', filename: '', error: err.message }) })
return () => { cancelled = true }
}, [repoFullName])
if (state.loading) return <p className="text-grey" style={{ fontSize: 13, marginTop: 8 }}>README wird geladen...</p>
if (state.error || !state.content) return null
return (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 13, color: '#94a3b8' }}>
<FaFileLines /><span>{state.filename}</span>
</div>
<div style={{ background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(148,163,184,0.2)', borderRadius: 8, padding: '12px 14px', fontSize: 13, color: '#e2e8f0', maxHeight: 320, overflowY: 'auto' }}>
<ReactMarkdown components={markdownComponents}>{state.content}</ReactMarkdown>
</div>
</div>
)
}
-2
View File
@@ -42,7 +42,6 @@ export default function QuickOverviewModal({ isOpen, onClose, workorders = [] })
<th>WOID</th>
<th>Created</th>
<th>Type</th>
<th>System</th>
<th>CID & CLO</th>
<th>Requested by</th>
<th>Topic</th>
@@ -61,7 +60,6 @@ export default function QuickOverviewModal({ isOpen, onClose, workorders = [] })
{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>
@@ -0,0 +1,34 @@
import { useState, useEffect, useCallback } from 'react'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import AssignedProjectCard from './AssignedProjectCard'
export default function TicketAssignedProjectsPanel({ ticket, refreshKey = 0 }) {
const { fetchByTicketId } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const loadProjects = useCallback(async () => {
if (!ticket?.$id) return
setLoading(true)
try {
setProjects(await fetchByTicketId(ticket.$id))
} catch {
setProjects([])
} finally {
setLoading(false)
}
}, [ticket?.$id, fetchByTicketId])
useEffect(() => { loadProjects() }, [loadProjects, refreshKey])
if (loading || projects.length === 0) return null
return (
<div style={{ background: 'rgba(45,55,72,0.5)', borderRadius: 12, padding: 20, border: '1px solid rgba(59,130,246,0.3)', marginTop: 20 }}>
<h5 style={{ fontWeight: 'bold', marginBottom: 12 }}>Zugewiesene Projekte ({projects.length})</h5>
{projects.map((project) => (
<AssignedProjectCard key={project.$id} project={project} />
))}
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { FaChevronDown, FaPlay, FaStop, FaPlus, FaClockRotateLeft, FaPen } 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 CreateWorksheetModal from './CreateWorksheetModal'
import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import WorksheetStats from './WorksheetStats'
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
import TicketProjectsModal from './TicketProjectsModal'
import TicketRoadmapPanel from './TicketRoadmapPanel'
import ProjectContextPanel from './ProjectContextPanel'
import InvoicePanel from './InvoicePanel'
import DocumentsPanel from './DocumentsPanel'
import Tabs from './ui/Tabs'
import Badge from './ui/Badge'
import { StatusPill, PriorityPill } from './ui/StatusPill'
import { useWorksheets } from '../hooks/useWorksheets'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { serviceTypeLabel, canEditWorksheetTimes } from '../lib/ticketForm'
const TABS = [
{ id: 'details', label: 'Details' },
{ id: 'worksheets', label: 'Arbeitsblaetter' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'project', label: 'Projekt / Preview' },
]
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 [showHistoryModal, setShowHistoryModal] = useState(false)
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheetTimes, 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()
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} />
<PriorityPill priority={ticket.priority} />
{ticket.type && <Badge tone="muted">{ticket.type}</Badge>}
{ticket.serviceType && <Badge tone="info">{serviceTypeLabel(ticket.serviceType)}</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>
<div style={{ minWidth: 110 }}>
<PriorityDropdown value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
</div>
<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">
<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} />
</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 hinzufuegen
</button>
<button className="ui-btn" onClick={() => setShowProjectsModal(true)} title="Projekte"><FaPen /></button>
<button className="ui-btn" onClick={() => setShowHistoryModal(true)} title="Verlauf"><FaClockRotateLeft /></button>
</div>
{worksheets.length > 0 && (
<div className="ui-card pad" style={{ marginBottom: 16 }}>
<WorksheetStats worksheets={worksheets} />
</div>
)}
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
/>
</div>
)}
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
{activeTab === 'project' && (
<div>
<TicketRoadmapPanel ticket={ticket} />
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
<FaPen /> Projekte zuweisen / bearbeiten
</button>
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
</div>
)}
</div>
</div>
)}
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} />
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
/>
)}
<StatusHistoryModal isOpen={showHistoryModal} onClose={() => setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaTimes } from 'react-icons/fa'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import AssignedProjectCard from './AssignedProjectCard'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
}
export default function TicketProjectsModal({ isOpen, onClose, ticket, onUpdated }) {
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selectedIds, setSelectedIds] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('')
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '' })
const loadProjects = useCallback(async () => {
if (!ticket?.$id) return
setLoading(true)
setError('')
try {
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
setAssigned(mine)
const assignedIds = new Set(mine.map((p) => p.$id))
setAvailable((all || []).filter((p) => p.repoFullName && !assignedIds.has(p.$id)))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [ticket, fetchAllProjects, fetchByTicketId])
useEffect(() => { if (isOpen) loadProjects() }, [isOpen, loadProjects])
if (!isOpen) return null
const handleAssign = async () => {
if (!selectedIds.length) return
setActionLoading(true)
const r = await assignProjects(selectedIds, { customerId: ticket.customerId || '', ticketId: ticket.$id })
setActionLoading(false)
if (r.success) { setSelectedIds([]); await loadProjects(); onUpdated?.() } else setError(r.error)
}
const handleCreate = async (e) => {
e.preventDefault()
setActionLoading(true)
setError('')
try {
await createProjectFromTemplate({
repoName: createForm.repoName || createForm.subdomain,
displayName: createForm.displayName,
subdomain: slugify(createForm.subdomain),
customerId: ticket.customerId || '',
ticketId: ticket.$id,
})
setCreateForm({ displayName: '', subdomain: '', repoName: '' })
await loadProjects()
onUpdated?.()
} catch (err) {
setError(err.message)
} finally {
setActionLoading(false)
}
}
const handleUnassign = async (projectId) => {
setActionLoading(true)
await unassignProject(projectId)
setActionLoading(false)
await loadProjects()
onUpdated?.()
}
return (
<div className="overlay ticket-projects-overlay">
<span className="overlay-close" onClick={onClose}>{'\u00d7'}</span>
<div className="overlay-content ticket-projects-modal">
<div className="ticket-projects-modal-header">
<h2>Projekte bearbeiten</h2>
<button type="button" className="btn btn-sm" onClick={onClose} aria-label="Schliessen"><FaTimes /></button>
</div>
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
{loading ? <p className="text-grey">Projekte werden geladen...</p> : (
<div className="ticket-projects-modal-grid">
<section className="ticket-projects-modal-section">
<h6>Zugewiesen</h6>
{assigned.length === 0 ? <p className="text-grey">Keine Projekte zugewiesen.</p> : assigned.map((p) => (
<AssignedProjectCard key={p.$id} project={p} showActions onUnassign={handleUnassign} />
))}
</section>
<div className="ticket-projects-modal-side">
<section className="ticket-projects-modal-section">
<h6>Verfuegbar zuweisen</h6>
{available.length === 0 ? <p className="text-grey">Keine weiteren Projekte verfuegbar.</p> : (
<div className="ticket-projects-available-list">
{available.map((p) => (
<label key={p.$id} className="ticket-projects-available-item">
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
<span>{p.projectName} ({p.subdomain || p.repoFullName})</span>
</label>
))}
</div>
)}
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
</section>
<section className="ticket-projects-modal-section">
<h6><FaPlus /> Neues Projekt</h6>
<form onSubmit={handleCreate} className="ticket-projects-create-form">
<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, repoName: f.repoName || slugify(e.target.value) }))} required />
<button type="submit" className="btn btn-dark" disabled={actionLoading}>{actionLoading ? 'Wird angelegt...' : 'Anlegen & zuweisen'}</button>
</form>
</section>
</div>
</div>
)}
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { IconRoute, IconAlertTriangle } from '@tabler/icons-react'
import RoadmapProgressBar from './roadmap/RoadmapProgressBar'
import {
fetchRoadmapByTicketId,
fetchRoadmapNodes,
computeProgress,
isNodeOverdue,
NODE_KIND,
NODE_STATUS,
ROADMAP_STATUS,
} from '../lib/roadmaps'
/**
* Roadmap-Tracking im Projektticket: Fortschritt, Mini-Uebersicht und Link zum Board.
* Rendert nichts, wenn zum Ticket keine Roadmap existiert.
*/
export default function TicketRoadmapPanel({ ticket }) {
const [roadmap, setRoadmap] = useState(null)
const [nodes, setNodes] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let cancelled = false
async function load() {
try {
const doc = await fetchRoadmapByTicketId(ticket.$id)
if (cancelled) return
setRoadmap(doc)
if (doc) setNodes(await fetchRoadmapNodes(doc.$id))
} catch {
/* Panel ist optional */
} finally {
if (!cancelled) setLoaded(true)
}
}
load()
return () => { cancelled = true }
}, [ticket.$id])
if (!loaded || !roadmap) return null
const progress = computeProgress(nodes)
const relevant = nodes.filter((n) => n.kind !== NODE_KIND.START)
const counts = {
open: relevant.filter((n) => (n.status || NODE_STATUS.OPEN) === NODE_STATUS.OPEN).length,
inProgress: relevant.filter((n) => n.status === NODE_STATUS.IN_PROGRESS).length,
done: relevant.filter((n) => n.status === NODE_STATUS.DONE).length,
}
const nextDue = relevant
.filter((n) => n.deadline && n.status !== NODE_STATUS.DONE)
.sort((a, b) => a.deadline.localeCompare(b.deadline))
.slice(0, 3)
const closedButActive = ticket.status === 'Closed' && roadmap.status === ROADMAP_STATUS.ACTIVE
return (
<div className="ui-card pad" style={{ marginBottom: 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 }}>
<IconRoute size={18} /> Roadmap: {roadmap.title}
</h5>
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">Zur Roadmap</Link>
</div>
<RoadmapProgressBar progress={progress} />
<div className="muted" style={{ display: 'flex', gap: 14, fontSize: 13, marginTop: 10, flexWrap: 'wrap' }}>
<span>Offen: {counts.open}</span>
<span>In Arbeit: {counts.inProgress}</span>
<span>Fertig: {counts.done}</span>
{roadmap.status === ROADMAP_STATUS.COMPLETED && <span style={{ color: '#34d399' }}>Plan abgeschlossen</span>}
</div>
{nextDue.length > 0 && (
<div style={{ marginTop: 10, fontSize: 13 }}>
<span className="faint">Naechste Deadlines: </span>
{nextDue.map((n, i) => (
<span key={n.$id} style={isNodeOverdue(n) ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
{i > 0 && ' · '}{n.title} ({n.deadline.split('-').reverse().join('.')})
</span>
))}
</div>
)}
{closedButActive && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 13, color: '#fbbf24' }}>
<IconAlertTriangle size={16} style={{ flexShrink: 0 }} />
Ticket ist geschlossen, aber der Plan ist erst zu {progress.percent}% fertig.
</div>
)}
</div>
)
}
+129 -176
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft } from 'react-icons/fa6'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
@@ -7,9 +7,14 @@ import PriorityDropdown from './PriorityDropdown'
import EditorDropdown from './EditorDropdown'
import ResponseDropdown from './ResponseDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import EditWorksheetTimeModal from './EditWorksheetTimeModal'
import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
import TicketProjectsModal from './TicketProjectsModal'
import InvoicePanel from './InvoicePanel'
import DocumentsPanel from './DocumentsPanel'
import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = {
@@ -20,14 +25,6 @@ const PRIORITY_CLASSES = {
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',
@@ -43,38 +40,44 @@ const APPROVAL_ICONS = {
'shipping': FaTruck
}
const TABS = [
{ id: 'details', label: 'Details' },
{ id: 'worksheets', label: 'Arbeitsblaetter' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'project', label: 'Projekt / Preview' },
]
export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true)
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimeWorksheet, setEditTimeWorksheet] = useState(null)
const [showHistoryModal, setShowHistoryModal] = useState(false)
// Worksheets für dieses Ticket laden (nur wenn expanded)
const {
worksheets,
loading: worksheetsLoading,
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
const {
worksheets,
loading: worksheetsLoading,
createWorksheet,
getTotalTime
updateWorksheet,
getTotalTime
} = useWorksheets(expanded ? ticket.woid : null)
// Startzeit fuer Git-Push-Worksheets nachtragen (Endzeit kam vom Push)
const handleSaveWorksheetTime = async (ws, data) => updateWorksheet(ws.$id, data)
// Wartungs-Abo: Arbeitszeit aufs Stundenkonto des Kunden buchen (Kundenportal zeigt den Stand)
const handleTogglePlan = async (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
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 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) {
@@ -88,15 +91,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
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, {
await onUpdate(ticket.$id, {
status: worksheetData.newStatus,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
})
}
return result
}
@@ -136,25 +136,19 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
{ticket.topic}
</td>
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<StatusDropdown
value={ticket.status}
onChange={handleStatusChange}
/>
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
</td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<PriorityDropdown
value={ticket.priority}
onChange={handlePriorityChange}
/>
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
</td>
<td
<td
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
rowSpan={2}
style={{ verticalAlign: 'middle' }}
>
<ApprovalIcon size={24} />
</td>
<td
<td
className="bg-dark-grey text-center text-white"
rowSpan={2}
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
@@ -165,132 +159,79 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</tr>
<tr className="ticket-row">
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<EditorDropdown
value={ticket.assignedTo}
onChange={handleEditorChange}
/>
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
</td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<ResponseDropdown
value={ticket.responseLevel}
onChange={handleResponseChange}
/>
<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={{
<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' }}>
{/* Tab-Leiste */}
<div className="ticket-tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="ticket-tab-content">
{activeTab === 'details' && (
<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 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
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'
}}>
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
{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
{activeTab === 'worksheets' && (
<div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
<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'
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
fontWeight: 'bold', cursor: 'pointer', flex: 1
}}
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)'
}}
type="button"
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
onClick={() => setShowProjectsModal(true)}
title="Projekte bearbeiten"
>
<FaPen size={18} />
</button>
<button
type="button"
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
onClick={() => setShowHistoryModal(true)}
title="Status History"
>
@@ -298,55 +239,67 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</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
}}>
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
<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>
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} onTogglePlan={handleTogglePlan} />
</div>
)}
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
{activeTab === 'project' && (
<div>
<button
type="button"
className="btn btn-green"
style={{ marginBottom: '12px' }}
onClick={() => setShowProjectsModal(true)}
>
<FaPen style={{ marginRight: 6 }} /> Projekte zuweisen / bearbeiten
</button>
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
</div>
)}
</div>
</div>
</td>
</tr>
</>
</div>
</td>
</tr>
)}
<CreateWorksheetModal
<CreateWorksheetModal
isOpen={showCreateWorksheet}
onClose={() => setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
/>
<EditWorksheetTimeModal
isOpen={Boolean(editTimeWorksheet)}
onClose={() => setEditTimeWorksheet(null)}
worksheet={editTimeWorksheet}
onSave={handleSaveWorksheetTime}
/>
<StatusHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
worksheets={worksheets}
ticket={ticket}
/>
<TicketProjectsModal
isOpen={showProjectsModal}
onClose={() => setShowProjectsModal(false)}
ticket={ticket}
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
/>
<tr className="spacer">
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
</tr>
+109
View File
@@ -0,0 +1,109 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaTrash } from 'react-icons/fa'
import PreviewLinkButton from './PreviewLinkButton'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import { WEBPAGE_TICKET_TYPE } from '../lib/appwrite'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
}
export default function WebpageProjectPanel({ ticket }) {
const { fetchAllProjects, fetchByTicketId, getAvailableProjects, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selectedIds, setSelectedIds] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('')
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '' })
const loadProjects = useCallback(async () => {
if (!ticket?.$id || ticket.type !== WEBPAGE_TICKET_TYPE) return
setLoading(true)
setError('')
try {
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
setAssigned(mine)
const ids = new Set(mine.map((p) => p.$id))
setAvailable(getAvailableProjects(all, ticket.customerId).filter((p) => !ids.has(p.$id)))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [ticket, fetchAllProjects, fetchByTicketId, getAvailableProjects])
useEffect(() => { loadProjects() }, [loadProjects])
if (ticket?.type !== WEBPAGE_TICKET_TYPE) return null
const handleAssign = async () => {
if (!selectedIds.length || !ticket.customerId) return
setActionLoading(true)
const r = await assignProjects(selectedIds, { customerId: ticket.customerId, ticketId: ticket.$id })
setActionLoading(false)
if (r.success) { setSelectedIds([]); await loadProjects() } else setError(r.error)
}
const handleCreate = async (e) => {
e.preventDefault()
if (!ticket.customerId) { setError('Kein Kunde am Ticket.'); return }
setActionLoading(true)
setError('')
try {
await createProjectFromTemplate({
repoName: createForm.repoName || createForm.subdomain,
displayName: createForm.displayName,
subdomain: slugify(createForm.subdomain),
customerId: ticket.customerId,
ticketId: ticket.$id,
})
setCreateForm({ displayName: '', subdomain: '', repoName: '' })
await loadProjects()
} catch (err) {
setError(err.message)
} finally {
setActionLoading(false)
}
}
return (
<div style={{ background: 'rgba(45,55,72,0.5)', borderRadius: 12, padding: 20, border: '1px solid rgba(59,130,246,0.3)', marginTop: 20 }}>
<h5 style={{ fontWeight: 'bold', marginBottom: 12 }}>Website-Projekte</h5>
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
{loading ? <p className="text-grey">Laden...</p> : (
<>
<section style={{ marginBottom: 16 }}>
<h6>Zugewiesen</h6>
{assigned.length === 0 ? <p className="text-grey">Keine Projekte.</p> : assigned.map((p) => (
<div key={p.$id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<span>{p.projectName} ({p.subdomain})</span>
<PreviewLinkButton href={p.previewUrl} />
<button type="button" className="btn btn-sm" onClick={() => unassignProject(p.$id).then(loadProjects)}><FaTrash /></button>
</div>
))}
</section>
<section style={{ marginBottom: 16 }}>
<h6>Verf�gbar zuweisen</h6>
{available.map((p) => (
<label key={p.$id} style={{ display: 'block' }}>
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
{' '}{p.projectName} ({p.subdomain})
</label>
))}
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
</section>
<section>
<h6><FaPlus /> Neues Projekt</h6>
<form onSubmit={handleCreate} style={{ display: 'grid', gap: 8, maxWidth: 400 }}>
<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, repoName: f.repoName || slugify(e.target.value) }))} required />
<button type="submit" className="btn btn-dark" disabled={actionLoading}>Anlegen & zuweisen</button>
</form>
</section>
</>
)}
</div>
)
}
+97 -23
View File
@@ -1,7 +1,18 @@
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen } from 'react-icons/fa'
import { serviceTypeLabel, needsTimeEntry } from '../lib/ticketForm'
export default function WorksheetList({ worksheets, totalTime, loading }) {
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, onEditTime, onTogglePlan, canEditTimes, onEditTimes }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
@@ -49,13 +60,14 @@ 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'
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: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
borderRadius: '8px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
@@ -70,7 +82,9 @@ 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
background: isGit
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
@@ -80,12 +94,16 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = ws.isComment
e.currentTarget.style.background = isGit
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
: ws.isComment
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = ws.isComment
e.currentTarget.style.background = isGit
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
}}
@@ -98,13 +116,27 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
)}
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && (
<span className="badge ms-2" style={{
{ws.isComment && !isGit && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
<FaComment className="me-1" /> Kommentar
</span>
)}
{needsTimeEntry(ws) && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
<FaClock className="me-1" /> Zeit offen
</span>
)}
{ws.countsToPlan && !ws.isComment && (
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{
background: '#9d3dfe'
}}>
Abo-Stunden
</span>
)}
</div>
{/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && (
@@ -122,13 +154,65 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
<span className="badge" style={{
background: 'rgba(255,255,255,0.2)',
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">
{needsTimeEntry(ws) && onEditTime && (
<button
type="button"
className="btn btn-sm"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onEditTime(ws)
}}
>
<FaClock className="me-1" /> Zeit nachtragen
</button>
)}
{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>
)}
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (
<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',
}}
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 */}
@@ -169,7 +253,7 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{ws.serviceType}</span>
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
{/* Status-Änderung */}
@@ -185,16 +269,6 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
</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>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'rgba(16, 185, 129, 0.05)',
+3 -2
View File
@@ -1,4 +1,5 @@
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
import { serviceTypeLabel } from '../lib/ticketForm'
export default function WorksheetStats({ worksheets }) {
if (!worksheets || worksheets.length === 0) {
@@ -29,9 +30,9 @@ export default function WorksheetStats({ worksheets }) {
}, {})
// Service Type Verteilung
// Service Type Verteilung (Remote und das alte Off Site zusammen als Fernwartung)
const byServiceType = worksheets.reduce((acc, ws) => {
const type = ws.serviceType || 'Unknown'
const type = serviceTypeLabel(ws.serviceType) || 'Unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
import { DateTimeInput } from './ui'
// 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, onClose, onSave }) {
const isGit = isGitWorksheet(worksheet)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende. Passte sie schon vorher nicht dazu,
// wurde sie von Hand eingetragen (z.B. ohne Pause) und bleibt stehen.
const [manualTotal, setManualTotal] = useState(() => {
const stored = Number(worksheet.totalTime) || 0
return !stored || stored === minutesBetween(formData.startTime, formData.endTime) ? null : stored
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const calculatedTotal = minutesBetween(formData.startTime, formData.endTime)
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()
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: parseInt(totalTime, 10) || 0
})
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 (Minuten)</label>
<input
id="worksheet-times-total"
type="number"
className="form-control"
min="0"
step="1"
value={totalTime}
onChange={(e) => setManualTotal(e.target.value)}
placeholder="0"
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{manualTotal === null && calculatedTotal !== null
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
</small>
</div>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { IconTrash, IconX } from '@tabler/icons-react'
import { Card, Button, Field } from '../ui'
import { useEmployees } from '../../hooks/useEmployees'
import { NODE_KIND, NODE_STATUS, NODE_STATUS_LABELS } from '../../lib/roadmaps'
const STATUS_ORDER = [NODE_STATUS.OPEN, NODE_STATUS.IN_PROGRESS, NODE_STATUS.DONE]
/**
* Seitliches Detail-Panel fuer den ausgewaehlten Knoten:
* Titel, Status, Zustaendiger, Deadline, Notizen, eingehende Verbindungen.
*/
export default function NodeDetailPanel({
node,
nodes,
readOnly,
onClose,
onSave,
onSetStatus,
onDelete,
onDisconnect,
}) {
const { employees } = useEmployees()
const [form, setForm] = useState({ title: '', assignedTo: '', deadline: '', notes: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setForm({
title: node.title || '',
assignedTo: node.assignedTo || '',
deadline: node.deadline || '',
notes: node.notes || '',
})
setError('')
}, [node.$id]) // eslint-disable-line react-hooks/exhaustive-deps
const isStart = node.kind === NODE_KIND.START
const incoming = (node.sourceIds || [])
.map((id) => nodes.find((n) => n.$id === id))
.filter(Boolean)
const handleSave = async () => {
if (!form.title.trim()) {
setError('Titel darf nicht leer sein.')
return
}
setSaving(true)
setError('')
const employee = employees.find((e) => e.userId === form.assignedTo)
const result = await onSave(node, {
title: form.title.trim(),
assignedTo: form.assignedTo,
assignedName: employee?.displayName || '',
deadline: form.deadline,
notes: form.notes,
})
setSaving(false)
if (!result.success) setError(result.error)
}
return (
<Card
title={isStart ? 'Start-Knoten' : node.kind === NODE_KIND.GOAL ? 'Ziel-Knoten' : 'Schritt'}
actions={<Button variant="ghost" onClick={onClose}><IconX size={16} /></Button>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Field label="Titel">
<input
type="text"
value={form.title}
maxLength={128}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
/>
</Field>
{!isStart && (
<Field label="Status">
<div style={{ display: 'flex', gap: 6 }}>
{STATUS_ORDER.map((status) => (
<Button
key={status}
variant={node.status === status ? 'primary' : 'default'}
disabled={readOnly}
onClick={() => onSetStatus(node, status)}
>
{NODE_STATUS_LABELS[status]}
</Button>
))}
</div>
</Field>
)}
{!isStart && (
<Field label="Zustaendig">
<select
value={form.assignedTo}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, assignedTo: e.target.value }))}
>
<option value="">Niemand</option>
{employees.map((e) => (
<option key={e.userId} value={e.userId}>{e.displayName}</option>
))}
</select>
</Field>
)}
{!isStart && (
<Field label="Deadline">
<input
type="date"
value={form.deadline}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, deadline: e.target.value }))}
/>
</Field>
)}
<Field label="Notizen">
<textarea
rows={4}
value={form.notes}
maxLength={2000}
disabled={readOnly}
placeholder="Details, Anforderungen, Links…"
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</Field>
{incoming.length > 0 && (
<Field label="Eingehende Verbindungen">
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{incoming.map((source) => (
<div key={source.$id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span className="muted">{source.title}</span>
{!readOnly && (
<Button variant="ghost" title="Verbindung loesen" onClick={() => onDisconnect(source.$id, node)}>
<IconX size={14} />
</Button>
)}
</div>
))}
</div>
</Field>
)}
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
{!readOnly && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
{!isStart ? (
<Button variant="ghost" onClick={() => onDelete(node)} title="Knoten loeschen">
<IconTrash size={15} /> Loeschen
</Button>
) : <span />}
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Speichere…' : 'Speichern'}
</Button>
</div>
)}
</div>
</Card>
)
}
+168
View File
@@ -0,0 +1,168 @@
import { useLayoutEffect, useRef, useState, useCallback } from 'react'
import { IconPlus } from '@tabler/icons-react'
import RoadmapNodeCard from './RoadmapNodeCard'
import { NODE_KIND } from '../../lib/roadmaps'
const COLUMN_WIDTH = 230
const COLUMN_GAP = 48
/**
* Steckbrett: Spalten von links (Start) nach rechts (Ziele), Knoten vertikal
* gestapelt, Kanten als SVG-Overlay. Verbinden per Klick-Modus:
* Connect-Handle am Quellknoten -> gueltige Ziele (Spalte weiter rechts) anklicken.
*/
export default function RoadmapBoard({
roadmap,
nodes,
selectedNode,
readOnly,
onSelectNode,
onAddNode,
onConnect,
}) {
const containerRef = useRef(null)
const nodeRefs = useRef({})
const [edges, setEdges] = useState([])
const [connectSource, setConnectSource] = useState(null)
const columnCount = Math.max(roadmap.columnCount || 3, 2)
const columns = Array.from({ length: columnCount }, (_, c) =>
nodes.filter((n) => n.column === c).sort((a, b) => (a.row || 0) - (b.row || 0))
)
const setNodeRef = useCallback((id) => (el) => {
if (el) nodeRefs.current[id] = el
else delete nodeRefs.current[id]
}, [])
// Kanten-Geometrie nach jedem Layout neu berechnen (Anker relativ zum Board)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const compute = () => {
const base = container.getBoundingClientRect()
const next = []
for (const node of nodes) {
for (const sourceId of node.sourceIds || []) {
const sourceEl = nodeRefs.current[sourceId]
const targetEl = nodeRefs.current[node.$id]
if (!sourceEl || !targetEl) continue
const s = sourceEl.getBoundingClientRect()
const t = targetEl.getBoundingClientRect()
next.push({
id: `${sourceId}-${node.$id}`,
x1: s.right - base.left,
y1: s.top + s.height / 2 - base.top,
x2: t.left - base.left,
y2: t.top + t.height / 2 - base.top,
})
}
}
setEdges(next)
}
compute()
const observer = new ResizeObserver(compute)
observer.observe(container)
return () => observer.disconnect()
}, [nodes, columnCount])
const handleStartConnect = (node) => setConnectSource(node)
const handleSelect = async (node) => {
if (connectSource) {
if (node.column > connectSource.column) {
await onConnect(connectSource, node)
}
setConnectSource(null)
return
}
onSelectNode(node)
}
const columnLabel = (c) => {
if (c === 0) return 'Start'
if (c === columnCount - 1) return 'Ziele'
return `Phase ${c}`
}
return (
<div style={{ overflowX: 'auto', paddingBottom: 8 }}>
{connectSource && (
<div
className="muted"
style={{ fontSize: 13, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 10 }}
>
Verbinden von <strong>{connectSource.title}</strong>: Zielknoten rechts davon anklicken.
<button type="button" className="ui-btn" onClick={() => setConnectSource(null)}>Abbrechen</button>
</div>
)}
<div
ref={containerRef}
style={{
position: 'relative',
display: 'grid',
gridTemplateColumns: `repeat(${columnCount}, ${COLUMN_WIDTH}px)`,
gap: `16px ${COLUMN_GAP}px`,
alignItems: 'start',
minWidth: columnCount * (COLUMN_WIDTH + COLUMN_GAP),
padding: '8px 4px',
}}
>
<svg
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }}
>
<defs>
<marker id="roadmap-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="var(--text-muted, #94a3b8)" />
</marker>
</defs>
{edges.map((e) => {
const dx = Math.max((e.x2 - e.x1) / 2, 24)
return (
<path
key={e.id}
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
fill="none"
stroke="var(--text-muted, #94a3b8)"
strokeWidth="1.5"
markerEnd="url(#roadmap-arrow)"
opacity="0.7"
/>
)
})}
</svg>
{columns.map((columnNodes, c) => (
<div key={c} style={{ display: 'flex', flexDirection: 'column', gap: 14, zIndex: 1 }}>
<div className="faint" style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{columnLabel(c)}
</div>
{columnNodes.map((node) => (
<RoadmapNodeCard
key={node.$id}
node={node}
selected={selectedNode?.$id === node.$id}
readOnly={readOnly}
connectSource={connectSource}
isValidTarget={Boolean(connectSource) && node.column > connectSource.column}
onSelect={handleSelect}
onStartConnect={handleStartConnect}
nodeRef={setNodeRef(node.$id)}
/>
))}
{!readOnly && c > 0 && (
<button
type="button"
className="ui-btn"
style={{ borderStyle: 'dashed', fontSize: 12 }}
onClick={() => onAddNode(c, c === columnCount - 1 ? NODE_KIND.GOAL : NODE_KIND.STEP, columnNodes.length)}
>
<IconPlus size={14} /> {c === columnCount - 1 ? 'Ziel' : 'Schritt'}
</button>
)}
</div>
))}
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { IconFlag, IconPlayerPlay, IconLink } from '@tabler/icons-react'
import { NODE_KIND, NODE_STATUS, isNodeOverdue } from '../../lib/roadmaps'
const STATUS_COLORS = {
[NODE_STATUS.OPEN]: 'var(--text-muted, #94a3b8)',
[NODE_STATUS.IN_PROGRESS]: '#fbbf24',
[NODE_STATUS.DONE]: '#34d399',
}
/**
* Knoten-Kachel im Steckbrett.
* connectMode: Board wartet auf Zielauswahl; isValidTarget markiert erlaubte Ziele.
*/
export default function RoadmapNodeCard({
node,
selected,
readOnly,
connectSource,
isValidTarget,
onSelect,
onStartConnect,
nodeRef,
}) {
const overdue = isNodeOverdue(node)
const isStart = node.kind === NODE_KIND.START
const isGoal = node.kind === NODE_KIND.GOAL
const connecting = Boolean(connectSource)
const isConnectSource = connectSource?.$id === node.$id
const borderColor = isConnectSource
? 'var(--accent, #3b82f6)'
: connecting && isValidTarget
? '#34d399'
: selected
? 'var(--accent, #3b82f6)'
: overdue
? 'var(--danger, #ef4444)'
: 'var(--border)'
return (
<div
ref={nodeRef}
onClick={() => onSelect(node)}
style={{
position: 'relative',
background: isGoal ? 'rgba(59,130,246,0.10)' : 'var(--surface-2, rgba(45,55,72,0.65))',
border: `2px solid ${borderColor}`,
borderRadius: 10,
padding: '10px 12px',
cursor: 'pointer',
opacity: connecting && !isValidTarget && !isConnectSource ? 0.45 : 1,
transition: 'border-color 0.15s ease, opacity 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isStart && <IconPlayerPlay size={15} style={{ flexShrink: 0 }} />}
{isGoal && <IconFlag size={15} style={{ flexShrink: 0, color: '#93c5fd' }} />}
{!isStart && (
<span
title={`Status: ${node.status || 'open'}`}
style={{
width: 10, height: 10, borderRadius: 5, flexShrink: 0,
background: STATUS_COLORS[node.status] || STATUS_COLORS.open,
}}
/>
)}
<span style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.title}
</span>
</div>
{(node.assignedName || node.deadline) && (
<div style={{ display: 'flex', gap: 8, marginTop: 6, fontSize: 11 }} className="faint">
{node.assignedName && <span>{node.assignedName}</span>}
{node.deadline && (
<span style={overdue ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
bis {node.deadline.split('-').reverse().join('.')}
</span>
)}
</div>
)}
{!readOnly && !isGoal && !connecting && (
<button
type="button"
title="Verbindung von hier starten"
onClick={(e) => { e.stopPropagation(); onStartConnect(node) }}
style={{
position: 'absolute', right: -11, top: '50%', transform: 'translateY(-50%)',
width: 22, height: 22, borderRadius: 11,
border: '1px solid var(--border)', background: 'var(--surface, #1e293b)',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'crosshair', padding: 0,
}}
>
<IconLink size={12} />
</button>
)}
</div>
)
}
@@ -0,0 +1,23 @@
/** Fortschrittsbalken fuer Roadmaps: Prozent + "x von y Knoten fertig". */
export default function RoadmapProgressBar({ progress, compact = false }) {
const { percent, done, total } = progress || { percent: 0, done: 0, total: 0 }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: compact ? 120 : 180 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span className="muted">{percent}%</span>
{!compact && <span className="faint">{done} von {total} fertig</span>}
</div>
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div
style={{
width: `${percent}%`,
height: '100%',
borderRadius: 3,
background: percent >= 100 ? 'var(--ok, #22c55e)' : 'var(--accent, #3b82f6)',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useId, useState } from 'react'
/* Textfeld mit Vorschlagsliste. Freie Eingaben bleiben erlaubt;
getSuggestions(text) liefert [{ value, hint }]. */
export default function AutocompleteInput({ value, onChange, getSuggestions, id, placeholder, required }) {
const listId = useId()
const [open, setOpen] = useState(false)
const [active, setActive] = useState(-1)
const items = open ? getSuggestions(value) : []
const showList = items.length > 0
const choose = (item) => {
onChange(item.value)
setOpen(false)
setActive(-1)
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
setActive((i) => Math.min(i + 1, items.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter' && showList && items[active]) {
e.preventDefault()
choose(items[active])
} else if (e.key === 'Escape' && showList) {
e.preventDefault()
setOpen(false)
}
}
return (
<div className="autocomplete">
<input
type="text"
id={id}
className="form-control"
role="combobox"
aria-autocomplete="list"
aria-expanded={showList}
aria-controls={listId}
aria-activedescendant={showList && items[active] ? `${listId}-${active}` : undefined}
autoComplete="off"
placeholder={placeholder}
required={required}
value={value}
onChange={(e) => {
onChange(e.target.value)
setOpen(true)
setActive(-1)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
setOpen(false)
setActive(-1)
}}
onKeyDown={handleKeyDown}
/>
{showList && (
<ul className="autocomplete-list" id={listId} role="listbox">
{items.map((item, index) => (
<li
key={item.value}
id={`${listId}-${index}`}
role="option"
aria-selected={index === active}
className={`autocomplete-item${index === active ? ' active' : ''}`}
// mousedown statt click: sonst verliert das Feld vorher den Fokus und die Liste schliesst
onMouseDown={(e) => {
e.preventDefault()
choose(item)
}}
onMouseEnter={() => setActive(index)}
>
<span className="autocomplete-value">{item.value}</span>
{item.hint && <span className="autocomplete-hint">{item.hint}</span>}
</li>
))}
</ul>
)}
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
return (
<span className={`badge badge-${tone} ${className}`} style={style}>
{dot && <span className="dot" />}
{children}
</span>
)
}
+23
View File
@@ -0,0 +1,23 @@
const VARIANTS = {
default: 'ui-btn',
primary: 'ui-btn ui-btn-primary',
ghost: 'ui-btn ui-btn-ghost',
danger: 'ui-btn ui-btn-danger',
}
export default function Button({
variant = 'default',
size,
as = 'button',
className = '',
children,
...props
}) {
const cls = `${VARIANTS[variant] || VARIANTS.default}${size === 'sm' ? ' ui-btn-sm' : ''} ${className}`
const Comp = as
return (
<Comp className={cls} {...props}>
{children}
</Comp>
)
}
+18
View File
@@ -0,0 +1,18 @@
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
if (!title && !actions) {
return (
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
{children}
</div>
)
}
return (
<div className={`ui-card ${className}`} style={style}>
<div className="ui-card-head">
<h3>{title}</h3>
{actions && <div className="flex gap-2 items-center">{actions}</div>}
</div>
<div className="ui-card-body" style={bodyStyle}>{children}</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
// Klick irgendwo ins Datumsfeld oeffnet den Kalender, nicht nur das kleine Symbol
const openCalendar = (e) => {
try {
e.currentTarget.showPicker?.()
} catch {
// aeltere Browser: Kalender bleibt ueber das Symbol erreichbar
}
}
/* Datum mit Kalender und Uhrzeit nebeneinander. Werte wie die Browser-Felder:
Datum "yyyy-mm-dd", Uhrzeit "hh:mm" - umgerechnet wird erst beim Speichern. */
export default function DateTimeInput({ id, date, time, onDateChange, onTimeChange, min, required, timeRequired, timeLabel }) {
return (
<div className="datetime-pair">
<input
id={id}
type="date"
className="form-control"
min={min || undefined}
required={required}
value={date}
onChange={(e) => onDateChange(e.target.value)}
onClick={openCalendar}
/>
<input
type="time"
className="form-control"
aria-label={timeLabel}
required={timeRequired}
value={time}
onChange={(e) => onTimeChange(e.target.value)}
/>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function EmptyState({ icon, title, hint, action }) {
return (
<div className="empty">
{icon && <div className="empty-icon">{icon}</div>}
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div>
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>}
{action && <div style={{ marginTop: 16 }}>{action}</div>}
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function Field({ label, children, hint }) {
return (
<div className="field">
{label && <label>{label}</label>}
{children}
{hint && <div className="faint" style={{ fontSize: 12 }}>{hint}</div>}
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
return (
<div className="kpi">
<span className="kpi-accent" style={{ background: accent }} />
<div className="kpi-label">{icon}{label}</div>
<div className="kpi-value">{value}</div>
{sub && <div className="kpi-sub">{sub}</div>}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
export default function PageHeader({ title, subtitle, actions, children }) {
return (
<div className="page-header">
<div>
<h1 className="page-title">{title}</h1>
{subtitle && <div className="page-subtitle">{subtitle}</div>}
{children}
</div>
{actions && <div className="page-actions">{actions}</div>}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
const STATUS_CLASS = {
Open: 'status-open',
Occupied: 'status-occupied',
Assigned: 'status-assigned',
Awaiting: 'status-awaiting',
'Added Info': 'status-awaiting',
Closed: 'status-closed',
}
const PRIORITY = {
0: { cls: 'priority-none', label: 'Keine' },
1: { cls: 'priority-low', label: 'Niedrig' },
2: { cls: 'priority-medium', label: 'Mittel' },
3: { cls: 'priority-high', label: 'Hoch' },
4: { cls: 'priority-critical', label: 'Kritisch' },
}
const pillBase = {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '3px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
}
export function StatusPill({ status }) {
const cls = STATUS_CLASS[status] || 'status-open'
return <span className={cls} style={pillBase}>{status || 'Open'}</span>
}
export function PriorityPill({ priority }) {
const p = PRIORITY[priority] ?? PRIORITY[1]
return <span className={p.cls} style={pillBase}>{p.label}</span>
}
+16
View File
@@ -0,0 +1,16 @@
export default function Tabs({ tabs, active, onChange }) {
return (
<div className="ticket-tabs">
{tabs.map((t) => (
<button
key={t.id}
type="button"
className={`ticket-tab ${active === t.id ? 'ticket-tab-active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export { default as PageHeader } from './PageHeader'
export { default as Card } from './Card'
export { default as Button } from './Button'
export { default as Badge } from './Badge'
export { StatusPill, PriorityPill } from './StatusPill'
export { default as Tabs } from './Tabs'
export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field'
export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput'
+120 -50
View File
@@ -1,26 +1,89 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { account, databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
import {
account,
databases,
DATABASE_ID,
COLLECTIONS,
ID,
Query,
hasAppwriteSession,
isDemoMode,
projectId as PROJECT_ID,
} from '../lib/appwrite'
const AuthContext = createContext()
// Demo mode when Appwrite is not configured
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
const DEMO_MODE = isDemoMode
// #region agent log
if (typeof window !== 'undefined') {
fetch('http://127.0.0.1:7284/ingest/0747da40-b90b-4354-9b84-c9b550a81ec9', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '252827' },
body: JSON.stringify({
sessionId: '252827',
runId: 'pre-fix',
hypothesisId: 'B',
location: 'AuthContext.jsx:init',
message: 'Auth DEMO_MODE resolved',
data: { DEMO_MODE, PROJECT_ID: PROJECT_ID ? PROJECT_ID.slice(0, 8) : '' },
timestamp: Date.now(),
}),
}).catch(() => {})
}
// #endregion
function clearStaleAppwriteSessions() {
if (typeof window === 'undefined' || !window.localStorage) return
try {
const raw = window.localStorage.getItem('cookieFallback')
if (!raw) return
const parsed = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return
for (const key of Object.keys(parsed)) {
if (key.startsWith('a_session_') && key !== `a_session_${PROJECT_ID}`) {
delete parsed[key]
}
}
window.localStorage.setItem('cookieFallback', JSON.stringify(parsed))
} catch {
window.localStorage.removeItem('cookieFallback')
}
}
function clearAllLocalAppwriteSessions() {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem('cookieFallback')
} catch { /* ignore */ }
}
async function createSessionWithCleanup(email, password) {
try {
await account.deleteSessions()
} catch { /* alte Sitzung ggf. nur serverseitig */ }
clearAllLocalAppwriteSessions()
if (account.createEmailPasswordSession) {
await account.createEmailPasswordSession(email, password)
} else {
await account.createEmailSession(email, password)
}
}
// Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu und liefert
// den Mitarbeiter-Eintrag (Kürzel = Gitea-Name, siehe isWorksheetCreator) oder null
async function ensureEmployeeExists(user) {
if (!user || DEMO_MODE) return null
// Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu
async function ensureEmployeeExists(user) {
if (!user || DEMO_MODE) return
try {
// Prüfe ob User bereits in employees Collection existiert
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.equal('userId', user.$id)]
)
// Wenn User noch nicht existiert, füge ihn hinzu
if (response.documents.length === 0) {
await databases.createDocument(
const employee = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
ID.unique(),
@@ -28,21 +91,24 @@ async function ensureEmployeeExists(user) {
userId: user.$id,
displayName: user.name || user.email,
email: user.email,
shortcode: '' // Kürzel wird später vom Admin hinzugefügt
shortcode: ''
}
)
console.log('✅ User automatisch zur Mitarbeiter-Liste hinzugefügt')
return employee
}
return response.documents[0]
} catch (error) {
// Fehler ignorieren wenn Collection nicht existiert oder Permissions fehlen
if (error.code !== 404) {
console.warn('Could not add user to employees collection:', error.message)
}
return null
}
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [employee, setEmployee] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -51,7 +117,6 @@ export function AuthProvider({ children }) {
async function checkUser() {
if (DEMO_MODE) {
// Check localStorage for demo session
const demoUser = localStorage.getItem('demo_user')
if (demoUser) {
setUser(JSON.parse(demoUser))
@@ -59,18 +124,24 @@ export function AuthProvider({ children }) {
setLoading(false)
return
}
if (!hasAppwriteSession()) {
setUser(null)
setLoading(false)
return
}
try {
const session = await account.get()
setUser(session)
// Automatisch zur employees Collection hinzufügen
await ensureEmployeeExists(session)
setEmployee(await ensureEmployeeExists(session))
} catch (error) {
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
if (error.code !== 401 && error.code !== 404) {
console.error('Unexpected error checking user:', error)
}
if (error.code === 401) {
clearAllLocalAppwriteSessions()
}
setUser(null)
} finally {
setLoading(false)
@@ -79,45 +150,45 @@ export function AuthProvider({ children }) {
async function login(email, password) {
if (DEMO_MODE) {
// Demo login - accept any credentials
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
localStorage.setItem('demo_user', JSON.stringify(demoUser))
setUser(demoUser)
return { success: true }
}
try {
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
try {
await account.createEmailSession(email, password)
} catch (e) {
// Fallback für ältere API
if (account.createEmailPasswordSession) {
await account.createEmailPasswordSession(email, password)
} else {
throw e
}
}
// User-Daten laden und automatisch zur employees Collection hinzufügen
const normalizedEmail = email.trim()
const normalizedPassword = password.trim()
clearStaleAppwriteSessions()
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
const session = await account.get()
setUser(session)
await ensureEmployeeExists(session)
setEmployee(await ensureEmployeeExists(session))
return { success: true }
} catch (error) {
console.error('Login error:', error)
let errorMessage = error.message || 'Login fehlgeschlagen'
// Bessere Fehlermeldungen
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
if (error.code === 429 || errorMessage.includes('Rate limit')) {
errorMessage = 'Zu viele Login-Versuche. Bitte 15–30 Minuten warten und es erneut versuchen.'
} else if (errorMessage.includes('missing scopes') && errorMessage.includes('account')) {
errorMessage = 'Session konnte nicht gespeichert werden. Bitte Seite neu laden und erneut versuchen.'
} else if (
error?.type === 'user_session_already_exists' ||
errorMessage.includes('session is active')
) {
errorMessage = 'Es gibt noch eine alte Sitzung. Bitte Seite neu laden und erneut anmelden.'
} else if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
errorMessage = 'Ungültige Email oder Passwort'
} else if (errorMessage.includes('User not found')) {
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
} else if (errorMessage.includes('Email/Password')) {
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
}
return { success: false, error: errorMessage }
}
}
@@ -128,28 +199,27 @@ export function AuthProvider({ children }) {
setUser(null)
return
}
try {
await account.deleteSession('current')
setUser(null)
await account.deleteSessions()
} catch (error) {
console.error('Logout error:', error)
} finally {
clearAllLocalAppwriteSessions()
setUser(null)
setEmployee(null)
}
}
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
const isAdmin = () => {
if (!user) return false
// Prüfe ob Benutzer das "admin" Label hat
return user.labels?.includes('admin') || false
}
const isAdmin = Boolean(user?.labels?.includes('admin'))
const value = {
user,
employee,
loading,
login,
logout,
isAdmin: isAdmin()
isAdmin
}
return (
+24 -6
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
import { databases, DATABASE_ID, COLLECTIONS, ID, isDemoMode } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
const DEMO_MODE = isDemoMode
// Default-Werte für Demo-Modus
const DEFAULT_CONFIG = {
ticketTypes: [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
],
@@ -31,6 +31,23 @@ const DEFAULT_CONFIG = {
]
}
function normalizePriorities(values) {
if (!Array.isArray(values)) return DEFAULT_CONFIG.priorities
const parsed = values.map((item, index) => {
if (typeof item === 'object' && item !== null) return item
if (typeof item === 'string') {
try {
const value = JSON.parse(item)
if (value && typeof value === 'object') return value
} catch {
return { value: index, label: item }
}
}
return null
}).filter(Boolean)
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.priorities
}
export function useAdminConfig() {
const [config, setConfig] = useState(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
@@ -56,7 +73,7 @@ export function useAdminConfig() {
systems: doc.systems || DEFAULT_CONFIG.systems,
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
priorities: doc.priorities || DEFAULT_CONFIG.priorities
priorities: normalizePriorities(doc.priorities)
})
} catch (e) {
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
@@ -99,7 +116,9 @@ export function useAdminConfig() {
systems: newConfig.systems,
responseLevels: newConfig.responseLevels,
serviceTypes: newConfig.serviceTypes,
priorities: newConfig.priorities
priorities: normalizePriorities(newConfig.priorities).map((priority) =>
JSON.stringify(priority)
)
}
try {
@@ -149,4 +168,3 @@ export function useAdminConfig() {
refresh: fetchConfig
}
}
+71 -31
View File
@@ -1,7 +1,19 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
import {
COLLECTIONS,
DATABASE_ID,
Query,
databases,
isDemoMode,
} from '../lib/appwrite'
import {
createCustomerWithPortalAccess,
deleteCustomerWithPortalAccess,
listCustomersForAdmin,
updateCustomerWithPortalAccess,
} from '../lib/customerAdminApi'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
const DEMO_MODE = isDemoMode
// Demo-Kunden für Testing
const DEMO_CUSTOMERS = [
@@ -22,12 +34,18 @@ export function useCustomers() {
}
try {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
[Query.orderAsc('name')]
)
setCustomers(response.documents)
try {
const response = await listCustomersForAdmin()
setCustomers(response.customers || [])
} catch {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
// Ohne Limit liefert Appwrite nur 25 Dokumente.
[Query.orderAsc('name'), Query.limit(5000)]
)
setCustomers(response.documents || [])
}
setError(null)
} catch (err) {
console.error('Error fetching customers:', err)
@@ -50,20 +68,25 @@ export function useCustomers() {
const createCustomer = async (data) => {
if (DEMO_MODE) {
const newCustomer = { ...data, $id: Date.now().toString() }
const { password, ...rest } = data
const newCustomer = {
...rest,
portalPassword: password || rest.portalPassword || '',
$id: Date.now().toString(),
}
setCustomers(prev => [...prev, newCustomer])
return { success: true, data: newCustomer }
}
try {
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
ID.unique(),
data
)
setCustomers(prev => [...prev, response])
return { success: true, data: response }
const { password, ...fields } = data
const result = await createCustomerWithPortalAccess({ ...fields, password })
const customer = {
...result.customer,
portalPassword: result.customer?.portalPassword || password || '',
}
setCustomers(prev => [...prev, customer])
return { success: true, data: customer }
} catch (err) {
return { success: false, error: err.message }
}
@@ -71,19 +94,41 @@ export function useCustomers() {
const updateCustomer = async (id, data) => {
if (DEMO_MODE) {
setCustomers(prev => prev.map(c => c.$id === id ? { ...c, ...data } : c))
const { password, ...rest } = data
setCustomers(prev =>
prev.map(c => {
if (c.$id !== id) return c
const next = { ...c, ...rest }
if (password) next.portalPassword = password
return next
})
)
return { success: true }
}
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
id,
data
const { password, ...fields } = data
const payload = { ...fields }
if (password) payload.password = password
const result = await updateCustomerWithPortalAccess(id, payload)
const customer = {
...result.customer,
portalPassword:
result.customer?.portalPassword ||
password ||
undefined,
}
setCustomers(prev =>
prev.map(c => {
if (c.$id !== id) return c
return {
...customer,
portalPassword: customer.portalPassword ?? c.portalPassword,
}
})
)
setCustomers(prev => prev.map(c => c.$id === id ? response : c))
return { success: true, data: response }
return { success: true, data: customer }
} catch (err) {
return { success: false, error: err.message }
}
@@ -96,11 +141,7 @@ export function useCustomers() {
}
try {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
id
)
await deleteCustomerWithPortalAccess(id)
setCustomers(prev => prev.filter(c => c.$id !== id))
return { success: true }
} catch (err) {
@@ -118,4 +159,3 @@ export function useCustomers() {
deleteCustomer
}
}
+71 -150
View File
@@ -1,47 +1,65 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
import { useCallback, useEffect, useState } from 'react'
import {
COLLECTIONS,
DATABASE_ID,
Query,
databases,
isDemoMode,
} from '../lib/appwrite'
import {
createEmployeeWithLogin,
deleteEmployeeWithLogin,
listEmployeesForAdmin,
updateEmployeeWithLogin,
} from '../lib/employeeAdminApi'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo-Mitarbeiter für Testing
const DEMO_MODE = isDemoMode
const DEMO_EMPLOYEES = [
{ $id: '1', userId: 'user1', displayName: 'Kenso Grimm', email: 'kenso@example.com', shortcode: 'KNSO' },
{ $id: '2', userId: 'user2', displayName: 'Christian Lehmann', email: 'christian@example.com', shortcode: 'CHLE' }
{
$id: '1',
userId: 'user1',
displayName: 'Kenso Grimm',
email: 'kenso@example.com',
shortcode: 'KNSO',
isAdmin: true,
},
{
$id: '2',
userId: 'user2',
displayName: 'Christian Lehmann',
email: 'christian@example.com',
shortcode: 'CHLE',
isAdmin: false,
},
]
export function useEmployees() {
const [employees, setEmployees] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [syncing, setSyncing] = useState(false)
const fetchEmployees = useCallback(async () => {
setLoading(true)
if (DEMO_MODE) {
setEmployees(DEMO_EMPLOYEES)
setLoading(false)
return
}
try {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.orderAsc('displayName')]
)
setEmployees(response.documents)
if (DEMO_MODE) {
setEmployees(DEMO_EMPLOYEES)
} else {
try {
const result = await listEmployeesForAdmin()
setEmployees(result.employees || [])
} catch {
const result = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.orderAsc('displayName')]
)
setEmployees(result.documents || [])
}
}
setError(null)
} catch (err) {
console.error('Error fetching employees:', err)
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
if (err.code === 404 || err.message?.includes('not found')) {
setEmployees([])
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
} else {
setError(err.message)
setEmployees([])
}
setEmployees([])
setError(err.message || 'Mitarbeiter konnten nicht geladen werden')
} finally {
setLoading(false)
}
@@ -52,141 +70,48 @@ export function useEmployees() {
}, [fetchEmployees])
const createEmployee = async (data) => {
if (DEMO_MODE) {
const newEmployee = { ...data, $id: Date.now().toString() }
setEmployees(prev => [...prev, newEmployee])
return { success: true, data: newEmployee }
}
try {
// Validierung
if (!data.userId || !data.displayName) {
return { success: false, error: 'userId und displayName sind erforderlich' }
}
const response = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
ID.unique(),
{
userId: data.userId,
displayName: data.displayName,
email: data.email || '',
shortcode: data.shortcode || ''
if (DEMO_MODE) {
const employee = {
...data,
userId: crypto.randomUUID(),
$id: Date.now().toString(),
}
)
setEmployees(prev => [...prev, response])
return { success: true, data: response }
setEmployees((current) => [...current, employee])
return { success: true, data: employee }
}
const result = await createEmployeeWithLogin(data)
setEmployees((current) => [...current, result.employee])
return { success: true, data: result.employee }
} catch (err) {
console.error('Error creating employee:', err)
return { success: false, error: err.message }
}
}
const updateEmployee = async (id, data) => {
if (DEMO_MODE) {
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
return { success: true }
}
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
id,
data
if (DEMO_MODE) {
setEmployees((current) =>
current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee)
)
return { success: true }
}
const result = await updateEmployeeWithLogin(id, data)
setEmployees((current) =>
current.map((employee) => employee.$id === id ? result.employee : employee)
)
setEmployees(prev => prev.map(e => e.$id === id ? response : e))
return { success: true, data: response }
return { success: true, data: result.employee }
} catch (err) {
console.error('Error updating employee:', err)
return { success: false, error: err.message }
}
}
const deleteEmployee = async (id) => {
if (DEMO_MODE) {
setEmployees(prev => prev.filter(e => e.$id !== id))
return { success: true }
}
try {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
id
)
setEmployees(prev => prev.filter(e => e.$id !== id))
if (!DEMO_MODE) await deleteEmployeeWithLogin(id)
setEmployees((current) => current.filter((employee) => employee.$id !== id))
return { success: true }
} catch (err) {
console.error('Error deleting employee:', err)
return { success: false, error: err.message }
}
}
/**
* Synchronisiert Appwrite Auth Users mit der employees Collection
* Erstellt fehlende Einträge für neue Users
*/
const syncWithAuthUsers = async () => {
if (DEMO_MODE) {
return { success: true, message: 'Demo-Modus: Keine Synchronisierung nötig' }
}
setSyncing(true)
try {
// 1. Lade alle Appwrite Auth Users
// Hinweis: In Appwrite 1.5.7 gibt es möglicherweise keine direkte List-Users API
// für normale User. Diese Funktion benötigt Server-Side Code oder Admin-API-Key.
// Für jetzt implementieren wir einen Workaround: Wir bieten ein manuelles Add-Interface.
// Alternative: Wenn der User Appwrite Admin ist, können wir versuchen:
// const users = await account.listUsers() // Funktioniert nur mit Admin-Rechten
// Da das nicht direkt möglich ist, geben wir eine Info zurück
return {
success: false,
error: 'Automatische Synchronisierung erfordert Admin-API-Zugriff. Bitte füge Mitarbeiter manuell hinzu oder verwende die Appwrite Server API.'
}
} catch (err) {
console.error('Error syncing auth users:', err)
return { success: false, error: err.message }
} finally {
setSyncing(false)
}
}
/**
* Erstellt einen Employee-Eintrag für den aktuell eingeloggten User
* Nützlich für Self-Service
*/
const createSelfEmployee = async (shortcode = '') => {
if (DEMO_MODE) {
return { success: true, message: 'Demo-Modus' }
}
try {
// Hole aktuellen User
const currentUser = await account.get()
// Prüfe, ob Employee bereits existiert
const existing = employees.find(e => e.userId === currentUser.$id)
if (existing) {
return { success: false, error: 'Mitarbeiter-Eintrag existiert bereits' }
}
// Erstelle Employee-Eintrag
const result = await createEmployee({
userId: currentUser.$id,
displayName: currentUser.name || currentUser.email,
email: currentUser.email,
shortcode: shortcode
})
return result
} catch (err) {
console.error('Error creating self employee:', err)
return { success: false, error: err.message }
}
}
@@ -195,13 +120,9 @@ export function useEmployees() {
employees,
loading,
error,
syncing,
refresh: fetchEmployees,
createEmployee,
updateEmployee,
deleteEmployee,
syncWithAuthUsers,
createSelfEmployee
}
}
+24
View File
@@ -0,0 +1,24 @@
import { useState, useEffect, useCallback } from 'react'
import { integrationsApi } from '../lib/integrationsApi'
export function useFinanceSummary() {
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
setSummary(await integrationsApi.invoicesSummary())
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { summary, loading, error, refresh: load }
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect, useCallback } from 'react'
import { integrationsApi } from '../lib/integrationsApi'
/**
* Laedt die InvoiceNinja-Rechnungen eines verknuepften Clients.
* clientId = customers.invoiceNinjaClientId (oder null -> nichts laden).
*/
export function useInvoices(clientId) {
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const fetchInvoices = useCallback(async () => {
if (!clientId) {
setInvoices([])
return
}
setLoading(true)
setError(null)
try {
const data = await integrationsApi.listInvoices(clientId)
setInvoices(data.invoices || [])
} catch (err) {
setError(err.message)
setInvoices([])
} finally {
setLoading(false)
}
}, [clientId])
useEffect(() => {
fetchInvoices()
}, [fetchInvoices])
const createInvoice = useCallback(
async (payload) => {
try {
const data = await integrationsApi.createInvoice(payload)
await fetchInvoices()
return { success: true, invoice: data.invoice }
} catch (err) {
return { success: false, error: err.message }
}
},
[fetchInvoices]
)
return { invoices, loading, error, refresh: fetchInvoices, createInvoice }
}
+282
View File
@@ -0,0 +1,282 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite'
const PAGE = 100
async function listAll(collection, queries = []) {
const out = []
let offset = 0
for (;;) {
const res = await databases.listDocuments(DATABASE_ID, collection, [
...queries,
Query.limit(PAGE),
Query.offset(offset),
])
out.push(...(res.documents || []))
if ((res.documents || []).length < PAGE) break
offset += PAGE
}
return out
}
/** Haengt an jeden Lead Verweise, die nicht am Lead selbst gespeichert sind,
* sondern ueber den Kunden verknuepft werden — via Lead -> Kunde (per E-Mail):
* - woid: WOID des Akquise-Tickets (workorders type=Akquise)
* - previewUrl: URL der erstellten, deployten Website (websiteProjects)
* Best effort; Fehler brechen die Lead-Liste nicht. */
async function attachLeadLinks(leads) {
const norm = (l) => (l.email || l.portalLogin || '').trim().toLowerCase()
const emails = [...new Set(leads.map(norm).filter(Boolean))]
if (!emails.length) return leads
// 1) Kunden per E-Mail -> customerId
const emailToCustomer = {}
const customerIds = []
for (let i = 0; i < emails.length; i += 90) {
const docs = await listAll(COLLECTIONS.CUSTOMERS, [Query.equal('email', emails.slice(i, i + 90))])
for (const c of docs) {
const e = (c.email || '').trim().toLowerCase()
if (e && !emailToCustomer[e]) {
emailToCustomer[e] = c.$id
customerIds.push(c.$id)
}
}
}
if (!customerIds.length) return leads
// 2) Akquise-Tickets per customerId -> kleinste (urspruengliche) WOID je Kunde
const customerToWoid = {}
for (let i = 0; i < customerIds.length; i += 90) {
const docs = await listAll(COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerIds.slice(i, i + 90)),
Query.equal('type', ['Akquise']),
])
for (const w of docs) {
const n = parseInt(w.woid, 10)
if (!w.customerId || isNaN(n)) continue
const cur = customerToWoid[w.customerId]
if (!cur || n < cur.n) customerToWoid[w.customerId] = { n, woid: w.woid, ticketId: w.$id }
}
}
// 3) deployte Preview je Kunde -> URL der erstellten Website.
// Die websiteProjects-Collection ist klein -> komplett laden + lokal zuordnen
// (die Preview haengt am selben Kunden, aber an einem anderen Repo als lead.repoUrl).
const customerToPreview = {}
const idSet = new Set(customerIds)
for (const p of await listAll(COLLECTIONS.WEBSITE_PROJECTS)) {
if (!idSet.has(p.customerId) || customerToPreview[p.customerId]) continue
const ready = p.status === 'deployed' || p.status === 'ready' || p.provisioningStatus === 'ready'
const url = p.previewUrl || (p.subdomain ? `https://${p.subdomain}.project.webklar.com` : '')
if (ready && url) customerToPreview[p.customerId] = url
}
// 4) an die Leads haengen
return leads.map((l) => {
const cid = emailToCustomer[norm(l)]
const t = customerToWoid[cid]
const previewUrl = customerToPreview[cid]
if (!t && !previewUrl) return l
return {
...l,
...(t ? { woid: t.woid, ticketId: t.ticketId } : {}),
...(previewUrl ? { previewUrl } : {}),
}
})
}
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`).
* Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline
* (processor.py auf dem Server) gerade Leads verarbeitet. */
export function useLeads() {
const [leads, setLeads] = useState([])
const [pipelines, setPipelines] = useState({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchLeads = useCallback(async () => {
try {
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
let enriched = out
try { enriched = await attachLeadLinks(out) } catch { /* Live-Join best effort */ }
setLeads(enriched)
setError(null)
} catch (err) {
if (err.code === 404 || err.message?.includes('not found')) {
setLeads([])
setError(null)
} else {
setError(err.message)
setLeads([])
}
} finally {
setLoading(false)
}
// Schritt-Log + E-Mail-Entwurf pro Lead (Zusatz-Collection, best effort)
try {
const docs = await listAll(COLLECTIONS.LEAD_PIPELINE)
const map = {}
for (const d of docs) if (d.leadId) map[d.leadId] = d
setPipelines(map)
} catch {
setPipelines({})
}
}, [])
useEffect(() => {
fetchLeads()
}, [fetchLeads])
// Live-Polling: 5s waehrend die Pipeline arbeitet, sonst alle 20s
const pipelineActive = useMemo(
() => leads.some((l) => l.pipelineStatus === 'running' || l.pipelineStatus === 'pending'),
[leads]
)
useEffect(() => {
const t = setInterval(fetchLeads, pipelineActive ? 5000 : 20000)
return () => clearInterval(t)
}, [fetchLeads, pipelineActive])
const updateLead = async (id, data) => {
try {
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEADS, id, {
...data,
updatedAt: new Date().toISOString(),
})
setLeads((prev) => prev.map((l) => (l.$id === id ? doc : l)))
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
const deleteLead = async (id) => {
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.LEADS, id)
setLeads((prev) => prev.filter((l) => l.$id !== id))
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
return { leads, pipelines, loading, error, refresh: fetchLeads, updateLead, deleteLead }
}
/** 5-Minuten-Zyklus des Server-Processors (leadSettings: lastRunAt/nextRunAt/
* workerBusy) + manueller Sofort-Trigger (runNow=true, der Processor pollt
* das Flag alle 5 Sekunden). */
export function useLeadCycle() {
const [cycle, setCycle] = useState(null)
const [triggering, setTriggering] = useState(false)
const fetchCycle = useCallback(async () => {
try {
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
setCycle({
lastRunAt: doc.lastRunAt || null,
nextRunAt: doc.nextRunAt || null,
runNow: Boolean(doc.runNow),
workerBusy: Boolean(doc.workerBusy),
})
} catch {
setCycle(null)
}
}, [])
useEffect(() => {
fetchCycle()
const t = setInterval(fetchCycle, 10000)
return () => clearInterval(t)
}, [fetchCycle])
const triggerNow = async () => {
setTriggering(true)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', {
runNow: true,
updatedAt: new Date().toISOString(),
})
await fetchCycle()
return { success: true }
} catch (err) {
return { success: false, error: err.message }
} finally {
setTriggering(false)
}
}
return { cycle, triggering, triggerNow, refresh: fetchCycle }
}
export const DEFAULT_LEAD_SETTINGS = {
branche: 'Autolackiererei',
staedte: [],
ganzDeutschland: false,
maxLeadsProStadt: 5,
minBewertung: 4.0,
emailPflicht: true,
nurSchlechteWebsite: true,
zusatzHinweise: '',
}
/** Sucheinstellungen der Routine (Appwrite `leadSettings`, Dokument `main`).
* Die Cloud-Routine liest sie vor jedem Lauf ueber den Server-Hook (/config). */
export function useLeadSettings() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchSettings = useCallback(async () => {
try {
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
setSettings(doc)
setError(null)
} catch (err) {
if (err.code === 404 || err.message?.includes('not found')) {
setSettings({ ...DEFAULT_LEAD_SETTINGS })
setError(null)
} else {
setError(err.message)
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const saveSettings = async (data) => {
const payload = {
branche: data.branche || '',
staedte: data.staedte || [],
ganzDeutschland: Boolean(data.ganzDeutschland),
maxLeadsProStadt: Number(data.maxLeadsProStadt) || 5,
minBewertung: Number(data.minBewertung) || 0,
emailPflicht: Boolean(data.emailPflicht),
nurSchlechteWebsite: Boolean(data.nurSchlechteWebsite),
zusatzHinweise: data.zusatzHinweise || '',
updatedAt: new Date().toISOString(),
}
try {
let doc
try {
doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
} catch (err) {
if (err.code === 404) {
doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
} else {
throw err
}
}
setSettings(doc)
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
return { settings, loading, error, refresh: fetchSettings, saveSettings }
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, ID, isDemoMode } from '../lib/appwrite'
import {
fetchRoadmapNodes,
logNodeStatusWorksheet,
allGoalsDone,
NODE_KIND,
NODE_STATUS,
} from '../lib/roadmaps'
export function useRoadmapNodes() {
const [nodes, setNodes] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const fetchNodes = useCallback(async (roadmapId) => {
if (!roadmapId || isDemoMode) return []
setLoading(true)
setError(null)
try {
const docs = await fetchRoadmapNodes(roadmapId)
setNodes(docs)
return docs
} catch (err) {
setError(err.message)
return []
} finally {
setLoading(false)
}
}, [])
const createNode = async (roadmapId, { title, column, row, kind }) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
const now = new Date().toISOString()
const doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
roadmapId,
title: title || 'Neuer Schritt',
kind: kind || NODE_KIND.STEP,
column,
row,
status: NODE_STATUS.OPEN,
createdAt: now,
updatedAt: now,
})
setNodes((prev) => [...prev, doc])
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
const updateNode = async (nodeId, data) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, nodeId, {
...data,
updatedAt: new Date().toISOString(),
})
setNodes((prev) => prev.map((n) => (n.$id === nodeId ? doc : n)))
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
/** Knoten loeschen inkl. Bereinigung eingehender Kanten bei den Nachfolgern. */
const deleteNode = async (node) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
if (node.kind === NODE_KIND.START) {
return { success: false, error: 'Der Start-Knoten kann nicht geloescht werden.' }
}
if (node.kind === NODE_KIND.GOAL && nodes.filter((n) => n.kind === NODE_KIND.GOAL).length <= 1) {
return { success: false, error: 'Ein Plan braucht mindestens ein Ziel.' }
}
try {
const successors = nodes.filter((n) => (n.sourceIds || []).includes(node.$id))
await Promise.all(successors.map((n) =>
databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, n.$id, {
sourceIds: (n.sourceIds || []).filter((id) => id !== node.$id),
updatedAt: new Date().toISOString(),
})
))
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id)
setNodes((prev) => prev
.filter((n) => n.$id !== node.$id)
.map((n) => ((n.sourceIds || []).includes(node.$id)
? { ...n, sourceIds: n.sourceIds.filter((id) => id !== node.$id) }
: n))
)
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
/** Kante anlegen: immer vorwaerts (Zielspalte > Quellspalte), keine Duplikate. */
const connectNodes = async (source, target) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
if (source.$id === target.$id) return { success: false, error: 'Knoten kann nicht mit sich selbst verbunden werden.' }
if (target.column <= source.column) {
return { success: false, error: 'Verbindungen laufen immer vorwaerts (nach rechts).' }
}
if ((target.sourceIds || []).includes(source.$id)) {
return { success: false, error: 'Diese Verbindung existiert bereits.' }
}
return updateNode(target.$id, { sourceIds: [...(target.sourceIds || []), source.$id] })
}
const disconnectNodes = async (sourceId, target) => {
return updateNode(target.$id, { sourceIds: (target.sourceIds || []).filter((id) => id !== sourceId) })
}
/** Statuswechsel inkl. Worksheet-Log am Projektticket; meldet, ob alle Ziele fertig sind. */
const setNodeStatus = async (roadmap, node, newStatus, user) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
const oldStatus = node.status || NODE_STATUS.OPEN
if (oldStatus === newStatus) return { success: true, allGoalsDone: false }
const result = await updateNode(node.$id, { status: newStatus })
if (!result.success) return result
try {
await logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user)
} catch {
/* Verlaufslog ist optional - Statuswechsel bleibt gueltig */
}
const updatedNodes = nodes.map((n) => (n.$id === node.$id ? { ...n, status: newStatus } : n))
return { success: true, data: result.data, allGoalsDone: allGoalsDone(updatedNodes) }
}
return {
nodes, loading, error, setNodes,
fetchNodes, createNode, updateNode, deleteNode,
connectNodes, disconnectNodes, setNodeStatus,
}
}
+100
View File
@@ -0,0 +1,100 @@
import { useState, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, isDemoMode } from '../lib/appwrite'
import {
createRoadmapPlan,
deleteRoadmapPlan,
completeRoadmap,
fetchRoadmapByTicketId,
} from '../lib/roadmaps'
export function useRoadmaps() {
const [roadmaps, setRoadmaps] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const fetchAll = useCallback(async () => {
if (isDemoMode) return []
setLoading(true)
setError(null)
try {
const response = await databases.listDocuments(
DATABASE_ID, COLLECTIONS.ROADMAPS,
[Query.orderDesc('$createdAt'), Query.limit(500)]
)
setRoadmaps(response.documents)
return response.documents
} catch (err) {
setError(err.message)
return []
} finally {
setLoading(false)
}
}, [])
const fetchById = useCallback(async (roadmapId) => {
if (!roadmapId || isDemoMode) return null
try {
return await databases.getDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId)
} catch {
return null
}
}, [])
const fetchByTicketId = useCallback(async (ticketId) => {
if (isDemoMode) return null
try {
return await fetchRoadmapByTicketId(ticketId)
} catch {
return null
}
}, [])
const createRoadmap = async (project, data, user) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
const result = await createRoadmapPlan(project, data, user)
setRoadmaps((prev) => [result.roadmap, ...prev])
return { success: true, data: result }
} catch (err) {
return { success: false, error: err.message }
}
}
const updateRoadmap = async (roadmapId, data) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId, {
...data,
updatedAt: new Date().toISOString(),
})
setRoadmaps((prev) => prev.map((r) => (r.$id === roadmapId ? doc : r)))
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
const deleteRoadmap = async (roadmap, user) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
await deleteRoadmapPlan(roadmap, user)
setRoadmaps((prev) => prev.filter((r) => r.$id !== roadmap.$id))
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
const completePlan = async (roadmap, user) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
const updated = await completeRoadmap(roadmap, user)
setRoadmaps((prev) => prev.map((r) => (r.$id === updated.$id ? updated : r)))
return { success: true, data: updated }
} catch (err) {
return { success: false, error: err.message }
}
}
return { roadmaps, loading, error, fetchAll, fetchById, fetchByTicketId, createRoadmap, updateRoadmap, deleteRoadmap, completePlan }
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, PREVIEW_TEMPLATE_NAME, isDemoMode } from '../lib/appwrite'
function isTemplateProject(project) {
const template = project.templateName || PREVIEW_TEMPLATE_NAME
return template === PREVIEW_TEMPLATE_NAME || Boolean(project.repoFullName)
}
export function useWebsiteProjects() {
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const fetchAllProjects = useCallback(async () => {
if (isDemoMode) return []
setLoading(true)
setError(null)
try {
const response = await databases.listDocuments(
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS,
[Query.orderDesc('$createdAt'), Query.limit(500)]
)
setProjects(response.documents)
return response.documents
} catch (err) {
setError(err.message)
return []
} finally {
setLoading(false)
}
}, [])
const fetchByTicketId = useCallback(async (ticketId) => {
if (!ticketId || isDemoMode) return []
try {
const response = await databases.listDocuments(
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS,
[Query.equal('ticketId', ticketId), Query.limit(100)]
)
return response.documents
} catch {
return []
}
}, [])
const getAvailableProjects = useCallback((allProjects, customerId) => {
return (allProjects || []).filter(isTemplateProject).filter((p) => {
if (!p.customerId) return true
return customerId && p.customerId === customerId
})
}, [])
const assignProject = async (projectId, { customerId, ticketId }) => {
try {
const response = await databases.updateDocument(
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
{ customerId, ticketId, updatedAt: new Date().toISOString() }
)
setProjects((prev) => prev.map((p) => (p.$id === projectId ? response : p)))
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
const assignProjects = async (projectIds, ctx) => {
const results = await Promise.all(projectIds.map((id) => assignProject(id, ctx)))
return results.find((r) => !r.success) || { success: true }
}
const unassignProject = async (projectId) => {
try {
await databases.updateDocument(
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
{ customerId: '', ticketId: '', updatedAt: new Date().toISOString() }
)
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
return { projects, loading, error, fetchAllProjects, fetchByTicketId, getAvailableProjects, assignProjects, unassignProject }
}
+116 -80
View File
@@ -1,7 +1,19 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
const DEMO_MODE = isDemoMode
function buildFiltersKey(filters = {}) {
return JSON.stringify({
limit: filters.limit ?? null,
customerId: filters.customerId ?? null,
assignedTo: filters.assignedTo ?? null,
status: filters.status ?? null,
type: filters.type ?? null,
priority: filters.priority ?? null,
woid: filters.woid ?? null,
})
}
// Demo data for testing without Appwrite
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
@@ -69,21 +81,34 @@ export function useWorkorders(filters = {}) {
const [workorders, setWorkorders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const filtersKey = useMemo(() => buildFiltersKey(filters), [
filters.limit,
filters.customerId,
filters.assignedTo,
filters.status,
filters.type,
filters.priority,
filters.woid,
])
const fetchWorkorders = useCallback(async () => {
const activeFilters = JSON.parse(filtersKey)
setLoading(true)
if (DEMO_MODE) {
// Filter demo data
let filtered = [...DEMO_WORKORDERS]
if (filters.status?.length > 0) {
filtered = filtered.filter(wo => filters.status.includes(wo.status))
if (activeFilters.status?.length > 0) {
filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
}
if (filters.priority?.length > 0) {
filtered = filtered.filter(wo => filters.priority.includes(wo.priority))
if (activeFilters.priority?.length > 0) {
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
}
if (filters.limit) {
filtered = filtered.slice(0, filters.limit)
if (activeFilters.woid) {
filtered = filtered.filter(wo => String(wo.woid) === String(activeFilters.woid))
}
if (activeFilters.limit) {
filtered = filtered.slice(0, activeFilters.limit)
}
setWorkorders(filtered)
setLoading(false)
@@ -93,39 +118,36 @@ export function useWorkorders(filters = {}) {
try {
const queries = [Query.orderDesc('$createdAt')]
if (filters.limit) {
queries.push(Query.limit(filters.limit))
if (activeFilters.limit) {
queries.push(Query.limit(activeFilters.limit))
}
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or()
// Wir filtern clientseitig für mehrere Werte
if (filters.status && filters.status.length > 0) {
if (filters.status.length === 1) {
queries.push(Query.equal('status', filters.status[0]))
}
// Für mehrere Werte: Clientseitig filtern (siehe unten)
// Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
// das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
// Tickets die neuesten Slots belegen und aktive verdraengen).
if (activeFilters.status && activeFilters.status.length > 0) {
queries.push(Query.equal('status', activeFilters.status))
}
if (activeFilters.type && activeFilters.type.length > 0) {
queries.push(Query.equal('type', activeFilters.type))
}
if (activeFilters.priority && activeFilters.priority.length > 0) {
queries.push(Query.equal('priority', activeFilters.priority))
}
if (filters.type && filters.type.length > 0) {
if (filters.type.length === 1) {
queries.push(Query.equal('type', filters.type[0]))
}
// Für mehrere Werte: Clientseitig filtern
if (activeFilters.customerId) {
queries.push(Query.equal('customerId', activeFilters.customerId))
}
if (filters.priority && filters.priority.length > 0) {
if (filters.priority.length === 1) {
queries.push(Query.equal('priority', filters.priority[0]))
}
// Für mehrere Werte: Clientseitig filtern
if (activeFilters.assignedTo) {
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
}
if (filters.customerId) {
queries.push(Query.equal('customerId', filters.customerId))
}
if (filters.assignedTo) {
queries.push(Query.equal('assignedTo', filters.assignedTo))
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
if (activeFilters.woid) {
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
}
// Debug: Zeige Collection ID
@@ -141,23 +163,8 @@ export function useWorkorders(filters = {}) {
COLLECTIONS.WORKORDERS,
queries
)
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
let filteredDocs = response.documents
if (filters.status && filters.status.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
}
if (filters.type && filters.type.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
}
if (filters.priority && filters.priority.length > 1) {
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
}
setWorkorders(filteredDocs)
setWorkorders(response.documents)
setError(null)
} catch (err) {
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
@@ -174,12 +181,60 @@ export function useWorkorders(filters = {}) {
} finally {
setLoading(false)
}
}, [filters])
}, [filtersKey])
useEffect(() => {
fetchWorkorders()
}, [fetchWorkorders])
const resolveAutomaticTicketFields = async (requestedAssignee = '') => {
const [allTicketsResponse, employeesResponse] = await Promise.all([
databases.listDocuments(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
[Query.limit(5000)]
),
databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
[Query.orderAsc('displayName'), Query.limit(500)]
),
])
const allTickets = allTicketsResponse.documents || []
const employees = employeesResponse.documents || []
const maxWoid = allTickets.reduce((highest, ticket) => {
const numericId = Number.parseInt(ticket.woid, 10)
return Number.isFinite(numericId) ? Math.max(highest, numericId) : highest
}, 9999)
let assignee = requestedAssignee
? employees.find((employee) => employee.userId === requestedAssignee)
: null
if (!assignee && employees.length > 0) {
const finishedStatuses = new Set(['Closed', 'Completed', 'Cancelled', 'Done'])
const activeCounts = new Map(employees.map((employee) => [employee.userId, 0]))
for (const ticket of allTickets) {
if (!ticket.assignedTo || finishedStatuses.has(ticket.status)) continue
if (activeCounts.has(ticket.assignedTo)) {
activeCounts.set(ticket.assignedTo, activeCounts.get(ticket.assignedTo) + 1)
}
}
assignee = [...employees].sort((left, right) => {
const loadDifference = activeCounts.get(left.userId) - activeCounts.get(right.userId)
if (loadDifference !== 0) return loadDifference
return (left.displayName || '').localeCompare(right.displayName || '', 'de')
})[0]
}
return {
woid: String(maxWoid + 1),
assignedTo: assignee?.userId || '',
assignedName: assignee?.displayName || '',
}
}
const createWorkorder = async (data) => {
if (DEMO_MODE) {
// Finde höchste WOID und +1
@@ -199,38 +254,16 @@ export function useWorkorders(filters = {}) {
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
}
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
// Generiere sequentielle 5-stellige WOID (wie im Original-System)
const generateWOID = () => {
// Finde die höchste bestehende WOID
if (workorders.length === 0) {
return '10000'; // Starte bei 10000 wenn keine Tickets existieren
}
const maxWoid = Math.max(
...workorders
.map(wo => parseInt(wo.woid))
.filter(woid => !isNaN(woid) && woid > 0)
);
// Wenn keine gültige WOID gefunden wurde, starte bei 10000
if (maxWoid === -Infinity || isNaN(maxWoid)) {
return '10000';
}
// Gib die nächste Nummer zurück (sequentiell)
return (maxWoid + 1).toString();
}
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open'
// Bereite Daten für Appwrite vor
const workorderData = {
// Required fields
topic: data.topic.trim(),
status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status
status: autoStatus,
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
woid: generateWOID(), // 5-stellige Zahl
woid: automatic.woid,
// Optional fields - nur senden wenn vorhanden
type: data.type || '',
@@ -238,7 +271,10 @@ export function useWorkorders(filters = {}) {
responseLevel: data.responseLevel || '',
serviceType: data.serviceType || 'Remote',
customerId: data.customerId || '',
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
customerName: data.customerName || '',
customerLocation: data.customerLocation || '',
assignedTo: automatic.assignedTo,
assignedName: automatic.assignedName,
requestedBy: data.requestedBy || '',
requestedFor: data.requestedFor || '',
startDate: data.startDate || '',
+20 -2
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
import { canEditWorksheetTimes, isGitWorksheet } from '../lib/ticketForm'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
const DEMO_MODE = isDemoMode
// Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
@@ -340,6 +341,7 @@ export function useWorksheets(woid = null) {
endTime: data.endTime || '',
details: data.details.trim(),
isComment: data.isComment || false,
countsToPlan: !data.isComment && Boolean(data.countsToPlan),
createdAt: new Date().toISOString()
}
@@ -387,6 +389,21 @@ export function useWorksheets(woid = null) {
}
}
/**
* Aendert nachtraeglich Beginn, Ende und Arbeitszeit - nur fuer den Ersteller
* (employee = eigener Eintrag aus "employees", fuer Git-Pushes)
*/
const updateWorksheetTimes = async (worksheet, times, currentUser, employee) => {
if (!canEditWorksheetTimes(worksheet, currentUser, employee)) {
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
}
const { startDate, startTime, endDate, endTime, totalTime } = times
const data = { startDate, startTime, endDate, endTime, totalTime }
// Mit nachgetragener Zeit zaehlt ein Git-Push als Arbeitszeit, nicht mehr als Kommentar
if (isGitWorksheet(worksheet)) data.isComment = false
return updateWorksheet(worksheet.$id, data)
}
/**
* Löscht ein Worksheet (sollte normalerweise nicht erlaubt sein - Audit Trail!)
*/
@@ -467,6 +484,7 @@ export function useWorksheets(woid = null) {
error,
createWorksheet,
updateWorksheet,
updateWorksheetTimes,
deleteWorksheet,
refresh: fetchWorksheets,
getTotalTime,
+54 -20
View File
@@ -1,24 +1,29 @@
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite'
// Debug: Zeige geladene Umgebungsvariablen (nur in Development)
/** Ticket-System Appwrite-Projekt (Fallback wenn keine .env beim Build) */
const DEFAULT_PROJECT_ID = '6a1058610003c5a13a05'
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1'
export const projectId = (
import.meta.env.VITE_APPWRITE_PROJECT_ID || DEFAULT_PROJECT_ID
).trim()
/** Demo-Modus nur wenn wirklich keine Projekt-ID (sollte mit Defaults nie passieren) */
export const isDemoMode = !projectId
if (import.meta.env.DEV) {
console.log('🔧 Appwrite Konfiguration:')
console.log('Endpoint:', import.meta.env.VITE_APPWRITE_ENDPOINT || 'NICHT GESETZT')
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID || 'NICHT GESETZT')
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'NICHT GESETZT')
console.log('Endpoint:', endpoint)
console.log('Project ID:', projectId || 'NICHT GESETZT')
console.log('Demo-Modus:', isDemoMode)
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database')
}
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1'
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
if (!projectId) {
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
console.error('Bitte überprüfe deine .env Datei im Root-Verzeichnis.')
}
const client = new Client()
.setEndpoint(endpoint)
.setProject(projectId)
const client = new Client().setEndpoint(endpoint).setProject(projectId)
export const account = new Account(client)
export const databases = new Databases(client)
@@ -26,18 +31,47 @@ export const storage = new Storage(client)
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
// Collection IDs - Verwende die tatsächlichen Collection IDs aus Appwrite!
export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt)
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets ✅
WORKORDERS: 'workorders',
CONFIG: 'config',
CUSTOMERS: 'customers',
EMPLOYEES: 'employees',
WORKSHEETS: 'worksheets',
USERS: 'users',
ATTACHMENTS: 'attachments'
ATTACHMENTS: 'attachments',
WEBSITE_PROJECTS: 'websiteProjects',
LEADS: 'leads',
LEAD_SETTINGS: 'leadSettings',
LEAD_PIPELINE: 'leadPipeline',
ROADMAPS: 'roadmaps',
ROADMAP_NODES: 'roadmapNodes',
}
export const BUCKET_ID = 'woms-attachments'
export const WEBPAGE_TICKET_TYPE = 'Webpage'
export const PROJECT_TICKET_TYPE = 'Project'
export const PREVIEW_TEMPLATE_NAME = 'webklar-preview-template'
export const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID || 'woms-attachments'
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
export function hasAppwriteSession() {
if (typeof window === 'undefined' || !projectId) return false
try {
const apiHost = new URL(endpoint).hostname
if (apiHost === window.location.hostname) {
return true
}
} catch {
/* ignore */
}
try {
const raw = window.localStorage.getItem('cookieFallback')
if (!raw) return false
const parsed = JSON.parse(raw)
return Boolean(parsed?.[`a_session_${projectId}`])
} catch {
return false
}
}
// Helper functions
export { ID, Query }
+40
View File
@@ -0,0 +1,40 @@
import { account } from './appwrite'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
async function adminFetch(path, { method = 'POST', body } = {}) {
const jwt = await account.createJWT()
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${jwt.jwt}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error || `API-Fehler ${response.status}`)
}
return data
}
export async function createCustomerWithPortalAccess(payload) {
return adminFetch('/api/admin/customers', { method: 'POST', body: payload })
}
export async function listCustomersForAdmin() {
return adminFetch('/api/admin/customers', { method: 'GET' })
}
export async function updateCustomerWithPortalAccess(customerId, payload) {
return adminFetch(`/api/admin/customers/${customerId}`, {
method: 'PATCH',
body: payload,
})
}
export async function deleteCustomerWithPortalAccess(customerId) {
return adminFetch(`/api/admin/customers/${customerId}`, { method: 'DELETE' })
}
+40
View File
@@ -0,0 +1,40 @@
import { account } from './appwrite'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
async function adminFetch(path, { method = 'GET', body } = {}) {
const jwt = await account.createJWT()
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${jwt.jwt}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error || `API-Fehler ${response.status}`)
}
return data
}
export function listEmployeesForAdmin() {
return adminFetch('/api/admin/employees')
}
export function createEmployeeWithLogin(payload) {
return adminFetch('/api/admin/employees', { method: 'POST', body: payload })
}
export function updateEmployeeWithLogin(employeeId, payload) {
return adminFetch(`/api/admin/employees/${employeeId}`, {
method: 'PATCH',
body: payload,
})
}
export function deleteEmployeeWithLogin(employeeId) {
return adminFetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE' })
}
+99
View File
@@ -0,0 +1,99 @@
import { account } from './appwrite'
/**
* Client fuer das Integrations-Backend (InvoiceNinja + Paperless).
* Same-Origin unter /api/integrations, authentifiziert per Appwrite-JWT.
*/
const BASE = '/api/integrations'
async function authHeaders(extra = {}) {
const { jwt } = await account.createJWT()
return { 'X-Appwrite-JWT': jwt, ...extra }
}
async function call(path, { method = 'GET', body } = {}) {
const headers = await authHeaders(body ? { 'Content-Type': 'application/json' } : {})
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Integrations-Fehler ${res.status}`)
}
return data
}
async function callBlob(path) {
const headers = await authHeaders()
const res = await fetch(`${BASE}${path}`, { headers })
if (!res.ok) throw new Error(`Download fehlgeschlagen (${res.status})`)
return res.blob()
}
function qs(params = {}) {
const sp = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') sp.set(k, v)
})
const s = sp.toString()
return s ? `?${s}` : ''
}
async function openBlobInNewTab(blob) {
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
export const integrationsApi = {
// ---- health ----
health: () => call('/health'),
// ---- InvoiceNinja: clients ----
listClients: (search = '') => call(`/clients${qs({ search })}`),
createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
updateClient: (id, payload) => call(`/clients/${encodeURIComponent(id)}`, { method: 'PUT', body: payload }),
// ---- InvoiceNinja: invoices ----
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
invoicesSummary: () => call('/invoices/summary'),
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
async openInvoicePdf(id) {
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
await openBlobInNewTab(blob)
},
// ---- Paperless ----
listDocuments: (params = {}) => call(`/paperless/documents${qs(params)}`),
ensureCorrespondent: (name) => call('/paperless/ensure-correspondent', { method: 'POST', body: { name } }),
ensureTag: (name) => call('/paperless/ensure-tag', { method: 'POST', body: { name } }),
uploadToPaperless: (payload) => call('/paperless/upload', { method: 'POST', body: payload }),
async openDocument(id, kind = 'preview') {
const blob = await callBlob(`/paperless/documents/${encodeURIComponent(id)}/${kind}`)
await openBlobInNewTab(blob)
},
}
// Oeffentliche InvoiceNinja-URL (fuer Deeplinks ins Portal)
export const INVOICE_NINJA_PUBLIC_URL = 'https://invoice.webklar.com'
// InvoiceNinja status_id -> deutsche Bezeichnung + Farbe
export const INVOICE_STATUS = {
1: { label: 'Entwurf', color: '#a0aec0' },
2: { label: 'Versendet', color: '#3b82f6' },
3: { label: 'Teilzahlung', color: '#f59e0b' },
4: { label: 'Bezahlt', color: '#10b981' },
5: { label: 'Storniert', color: '#ef4444' },
'-1': { label: 'Storniert', color: '#ef4444' },
}
export function formatMoney(amount, currency = 'EUR') {
const n = Number(amount || 0)
try {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n)
} catch {
return `${n.toFixed(2)} ${currency}`
}
}
+130
View File
@@ -0,0 +1,130 @@
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
import { integrationsApi } from './integrationsApi'
/** Pflichtfelder, damit eine Rechnung erstellt werden kann (wie InvoiceNinja). */
export function isInvoiceReady(c) {
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
}
export function customerStage(c) {
const s = (c?.customerStatus || '').toLowerCase()
if (s === 'lead') return 'lead'
if (s === 'lost') return 'lost'
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
}
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
export function isArchived(c) {
return Boolean(c?.archived)
}
/** Kunde archivieren / wiederherstellen. */
export async function setCustomerArchived(customer, archived) {
return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
archived: Boolean(archived),
updatedAt: new Date().toISOString(),
})
}
function ninjaPayload(c) {
return {
name: c.companyName || c.name,
email: c.email || '',
phone: c.phone || '',
street: c.street || '',
city: c.city || '',
postalCode: c.postalCode || '',
country: c.country || '',
vatNumber: c.vatNumber || '',
contactName: c.contactName || '',
}
}
/** Erstellt/aktualisiert den InvoiceNinja-Client und speichert die ID am Kunden. */
export async function syncCustomerToNinja(customer) {
if (customer.invoiceNinjaClientId) {
await integrationsApi.updateClient(customer.invoiceNinjaClientId, ninjaPayload(customer))
return customer.invoiceNinjaClientId
}
const r = await integrationsApi.createClient(ninjaPayload(customer))
const id = r.client?.id
if (id) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
invoiceNinjaClientId: id,
})
}
return id
}
async function nextWoid() {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.orderDesc('$createdAt'),
Query.limit(200),
])
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
return String((nums.length ? Math.max(...nums) : 9999) + 1)
} catch {
return String(10000)
}
}
/** Auto-Akquise-Ticket fuer einen potenziellen Kunden. */
export async function createAcquisitionTicket(customer, user) {
const woid = await nextWoid()
const data = {
topic: `Akquise: ${customer.name}`,
status: 'Open',
priority: 2,
woid,
type: 'Akquise',
systemType: 'n/a',
serviceType: 'Remote',
customerId: customer.$id,
customerName: customer.name,
customerLocation: customer.location || '',
requestedBy: customer.email || '',
assignedTo: user?.$id || '',
details:
`Automatisch erstelltes Akquise-Ticket fuer den potenziellen Kunden "${customer.name}".\n` +
`Kontakt: ${customer.email || '-'}\n\n` +
`Worksheets dokumentieren den Kontaktverlauf (sichtbar in den WSIDs).\n` +
`Ergebnis im Worksheet auf "Zugesagt" -> Lead wird automatisch fester Kunde. "Abgesagt" -> Ticket geschlossen.`,
createdAt: new Date().toISOString(),
}
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
}
/** Legt einen Lead an (nur Unternehmen + E-Mail) und erstellt das Akquise-Ticket. */
export async function createLead({ company, email }, user) {
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
name: company,
companyName: company,
email: email || '',
customerStatus: 'lead',
updatedAt: new Date().toISOString(),
})
let ticket = null
try {
ticket = await createAcquisitionTicket(customer, user)
} catch {
/* Ticket ist optional - Lead bleibt bestehen */
}
return { customer, ticket }
}
/** Macht aus einem Lead einen festen Kunden (mit vollstaendigen Daten) und legt den InvoiceNinja-Client an. */
export async function upgradeToCustomer(customer, fullData) {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
...fullData,
customerStatus: 'customer',
updatedAt: new Date().toISOString(),
})
let clientId = updated.invoiceNinjaClientId
try {
clientId = await syncCustomerToNinja(updated)
} catch (e) {
return { customer: updated, clientId: null, syncError: e.message }
}
return { customer: { ...updated, invoiceNinjaClientId: clientId }, clientId }
}
+75
View File
@@ -0,0 +1,75 @@
/* Planboard-Logik: aus Tickets werden Termine.
Termine sind kein eigener Datensatz - sie haengen an den Tickets selbst
(startDate/startTime/endTime/assignedTo). Die Rechnungsanalyse im
Kundenportal schreibt genau diese Felder, deshalb erscheint ein dort
vereinbarter Termin hier ohne Umweg.
WOMS speichert Datum als Text "dd.mm.yyyy" (auch einstellig: "2.5.2026")
und Uhrzeit als "hhmm". Beides wird hier tolerant gelesen. */
export function parseGermanDate(value) {
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
if (!m) return null
const day = Number(m[1])
const month = Number(m[2])
const year = Number(m[3])
const date = new Date(year, month - 1, day)
// "31.02.2026" wuerde sonst still zum 3. Maerz werden
if (date.getDate() !== day || date.getMonth() !== month - 1 || date.getFullYear() !== year) return null
return date
}
export function formatTime(value) {
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
if (!m) return ''
const hour = Number(m[1])
const minute = Number(m[2])
if (hour > 23 || minute > 59) return ''
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
export function dayKey(date) {
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
export function toAppointment(workorder, employeeByUserId = {}) {
const date = parseGermanDate(workorder.startDate)
if (!date) return null
const employee = employeeByUserId[workorder.assignedTo]
return {
id: workorder.$id,
woid: workorder.woid || '',
date,
key: dayKey(date),
time: formatTime(workorder.startTime),
endTime: formatTime(workorder.endTime),
topic: workorder.topic || workorder.title || 'Ohne Betreff',
type: workorder.type || '',
status: workorder.status || '',
customerName: workorder.customerName || '',
employeeName: employee?.displayName || workorder.assignedName || '',
employeeShort: employee?.shortcode || '',
}
}
/* Termine nach Tag ("yyyy-mm-dd"), innerhalb des Tages nach Uhrzeit.
Eintraege ohne Uhrzeit landen am Ende des Tages. */
export function groupAppointmentsByDay(workorders = [], employees = []) {
const employeeByUserId = {}
for (const e of employees) employeeByUserId[e.userId] = e
const map = {}
for (const wo of workorders) {
const appointment = toAppointment(wo, employeeByUserId)
if (!appointment) continue
;(map[appointment.key] = map[appointment.key] || []).push(appointment)
}
for (const key of Object.keys(map)) {
map[key].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
}
return map
}
export function countInRange(byDay, days = []) {
return days.reduce((sum, d) => sum + (byDay[dayKey(d)]?.length || 0), 0)
}
+52
View File
@@ -0,0 +1,52 @@
import { account } from './appwrite'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
async function adminFetch(path, options = {}) {
const jwt = await account.createJWT()
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${jwt.jwt}`,
'Content-Type': 'application/json',
...options.headers,
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `API-Fehler ${response.status}`)
return data
}
export async function createProjectFromTemplate(payload) {
return adminFetch('/api/admin/website-projects/create-from-template', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function syncGiteaRepos() {
return adminFetch('/api/admin/gitea/sync-repos', { method: 'POST' })
}
export async function updateWebsiteProject(id, payload) {
return adminFetch(`/api/admin/website-projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function archiveWebsiteProject(id) {
return adminFetch(`/api/admin/website-projects/${id}/archive`, { method: 'POST' })
}
export async function unarchiveWebsiteProject(id) {
return adminFetch(`/api/admin/website-projects/${id}/unarchive`, { method: 'POST' })
}
export async function fetchProjectReadme(repoFullName) {
if (!repoFullName) return { found: false }
return adminFetch(
`/api/admin/gitea/repo-readme?repoFullName=${encodeURIComponent(repoFullName)}`
)
}
+292
View File
@@ -0,0 +1,292 @@
import { databases, DATABASE_ID, COLLECTIONS, PROJECT_TICKET_TYPE, Query, ID } from './appwrite'
/**
* Roadmaps: projektspezifische Plaene als Steckbrett (Spalten-Raster).
* Ein Dokument pro Knoten; Kanten als sourceIds (Vorgaenger) am Zielknoten.
* Genau 1 aktiver Plan pro Projekt (Pruefung hier, nicht per Index erzwingbar).
* Jeder Plan erzeugt automatisch ein Projektticket (Vorbild: createAcquisitionTicket in leads.js).
*/
export const ROADMAP_STATUS = { ACTIVE: 'active', COMPLETED: 'completed' }
export const NODE_STATUS = { OPEN: 'open', IN_PROGRESS: 'in_progress', DONE: 'done' }
export const NODE_KIND = { START: 'start', STEP: 'step', GOAL: 'goal' }
export const NODE_STATUS_LABELS = {
open: 'Offen',
in_progress: 'In Arbeit',
done: 'Fertig',
}
/** Fortschritt in Prozent: fertige Knoten / alle Knoten ausser Start. */
export function computeProgress(nodes) {
const relevant = (nodes || []).filter((n) => n.kind !== NODE_KIND.START)
if (!relevant.length) return { percent: 0, done: 0, total: 0 }
const done = relevant.filter((n) => n.status === NODE_STATUS.DONE).length
return { percent: Math.round((done / relevant.length) * 100), done, total: relevant.length }
}
export function allGoalsDone(nodes) {
const goals = (nodes || []).filter((n) => n.kind === NODE_KIND.GOAL)
return goals.length > 0 && goals.every((n) => n.status === NODE_STATUS.DONE)
}
export function isNodeOverdue(node) {
if (!node?.deadline || node.status === NODE_STATUS.DONE) return false
return node.deadline < new Date().toISOString().slice(0, 10)
}
/** Aktiver Plan eines Projekts (oder null). */
export async function fetchActiveRoadmapByProject(projectId) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
Query.equal('projectId', projectId),
Query.equal('status', ROADMAP_STATUS.ACTIVE),
Query.limit(1),
])
return res.documents[0] || null
}
export async function fetchRoadmapByTicketId(ticketId) {
if (!ticketId) return null
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
Query.equal('ticketId', ticketId),
Query.limit(1),
])
return res.documents[0] || null
}
export async function fetchRoadmapNodes(roadmapId) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, [
Query.equal('roadmapId', roadmapId),
Query.limit(500),
])
return res.documents
}
async function nextWoid() {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.orderDesc('$createdAt'),
Query.limit(200),
])
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
return String((nums.length ? Math.max(...nums) : 9999) + 1)
} catch {
return String(10000)
}
}
async function nextWsid() {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
Query.orderDesc('wsid'),
Query.limit(1),
])
const highest = parseInt(res.documents[0]?.wsid)
return String(isNaN(highest) ? 100000 : highest + 1)
} catch {
return String(100000)
}
}
/** Automatisches Projektticket fuer einen neuen Plan. */
async function createRoadmapTicket(roadmap, project, user) {
const woid = await nextWoid()
const data = {
topic: `Projekt: ${project.projectName}`,
status: 'Open',
priority: 2,
woid,
type: PROJECT_TICKET_TYPE,
systemType: 'n/a',
serviceType: 'Remote',
assignedTo: user?.$id || '',
details:
`Automatisch erstelltes Projektticket fuer die Roadmap "${roadmap.title}" ` +
`zum Projekt "${project.projectName}".\n\n` +
`Der Planfortschritt wird hier getrackt: Jede Statusaenderung eines Roadmap-Knotens ` +
`erzeugt einen Verlaufseintrag (Worksheet). Sind alle Ziel-Knoten fertig, wird das ` +
`Ticket geschlossen und der Plan archiviert.`,
createdAt: new Date().toISOString(),
}
if (project.customerId) {
data.customerId = project.customerId
if (roadmap.customerName) data.customerName = roadmap.customerName
}
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
}
/**
* Legt einen neuen Plan an: Roadmap-Dokument + Start-Knoten + Ziel-Knoten + Auto-Ticket.
* Wirft, wenn das Projekt bereits einen aktiven Plan hat.
*/
export async function createRoadmapPlan(project, { title, goalTitle }, user) {
const existing = await fetchActiveRoadmapByProject(project.$id)
if (existing) {
throw new Error(`Das Projekt "${project.projectName}" hat bereits einen aktiven Plan.`)
}
let customerName = ''
if (project.customerId) {
try {
const customer = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, project.customerId)
customerName = customer.name || ''
} catch {
/* Kunde optional */
}
}
const now = new Date().toISOString()
const columnCount = 3
const roadmap = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, ID.unique(), {
projectId: project.$id,
projectName: project.projectName || '',
customerId: project.customerId || '',
customerName,
title: title || `Plan: ${project.projectName}`,
status: ROADMAP_STATUS.ACTIVE,
columnCount,
createdBy: user?.$id || '',
createdAt: now,
updatedAt: now,
})
await Promise.all([
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
roadmapId: roadmap.$id,
title: 'Start',
kind: NODE_KIND.START,
column: 0,
row: 0,
status: NODE_STATUS.OPEN,
createdAt: now,
updatedAt: now,
}),
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
roadmapId: roadmap.$id,
title: goalTitle || 'Ziel',
kind: NODE_KIND.GOAL,
column: columnCount - 1,
row: 0,
status: NODE_STATUS.OPEN,
createdAt: now,
updatedAt: now,
}),
])
let ticket = null
try {
ticket = await createRoadmapTicket(roadmap, project, user)
await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
ticketId: ticket.$id,
woid: ticket.woid,
})
roadmap.ticketId = ticket.$id
roadmap.woid = ticket.woid
} catch {
/* Ticket ist optional - Plan bleibt bestehen */
}
return { roadmap, ticket }
}
/** Verlaufseintrag (Worksheet-Kommentar) am Projektticket, ohne den Ticket-Status zu aendern. */
export async function logRoadmapWorksheet(roadmap, detailsText, user) {
if (!roadmap?.ticketId) return null
let ticket
try {
ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
} catch {
return null // Ticket geloescht -> kein Log
}
const today = new Date().toISOString().slice(0, 10)
const wsid = await nextWsid()
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
wsid,
woid: ticket.woid || roadmap.woid || '',
workorderId: ticket.$id,
employeeId: user?.$id || 'system',
employeeName: user?.name || user?.email || 'System',
serviceType: 'Remote',
oldStatus: ticket.status,
newStatus: ticket.status,
totalTime: 0,
startDate: today,
endDate: today,
details: detailsText,
isComment: true,
createdAt: new Date().toISOString(),
})
}
/** Knoten-Statuswechsel im Ticketverlauf dokumentieren. */
export async function logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user) {
const from = NODE_STATUS_LABELS[oldStatus] || oldStatus || '-'
const to = NODE_STATUS_LABELS[newStatus] || newStatus
return logRoadmapWorksheet(roadmap, `Roadmap-Knoten "${node.title}": ${from} -> ${to}`, user)
}
/**
* Plan abschliessen: Roadmap archivieren, Ticket schliessen (falls noch offen)
* und den Abschluss im Verlauf dokumentieren.
*/
export async function completeRoadmap(roadmap, user) {
const now = new Date().toISOString()
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
status: ROADMAP_STATUS.COMPLETED,
completedAt: now,
updatedAt: now,
})
if (roadmap.ticketId) {
try {
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
if (ticket.status !== 'Closed') {
const wsid = await nextWsid()
const today = now.slice(0, 10)
await databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
wsid,
woid: ticket.woid || '',
workorderId: ticket.$id,
employeeId: user?.$id || 'system',
employeeName: user?.name || user?.email || 'System',
serviceType: 'Remote',
oldStatus: ticket.status,
newStatus: 'Closed',
totalTime: 0,
startDate: today,
endDate: today,
details: `Roadmap "${roadmap.title}" abgeschlossen: alle Ziel-Knoten fertig. Ticket automatisch geschlossen.`,
isComment: true,
createdAt: now,
})
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Closed' })
} else {
await logRoadmapWorksheet({ ...roadmap, status: ROADMAP_STATUS.COMPLETED }, `Roadmap "${roadmap.title}" abgeschlossen (Ticket war bereits geschlossen).`, user)
}
} catch {
/* Ticket fehlt -> nur archivieren */
}
}
return updated
}
/**
* Aktiven Plan loeschen (destruktiv): alle Knoten + Roadmap entfernen,
* Auto-Ticket auf Cancelled setzen. Abgeschlossene Plaene werden nie geloescht.
*/
export async function deleteRoadmapPlan(roadmap, user) {
const nodes = await fetchRoadmapNodes(roadmap.$id)
await logRoadmapWorksheet(roadmap, `Roadmap "${roadmap.title}" geloescht. Ticket wird abgebrochen.`, user)
for (const node of nodes) {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id)
}
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id)
if (roadmap.ticketId) {
try {
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
if (ticket.status !== 'Closed' && ticket.status !== 'Cancelled') {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Cancelled' })
}
} catch {
/* Ticket fehlt -> nichts zu tun */
}
}
}
+163
View File
@@ -0,0 +1,163 @@
/* Hilfen fuer die Formulare "Neues Ticket" und "Arbeitsblatt" und die Anzeige.
WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als "hhmm"
(siehe planboard.js). Die Kalender-Felder des Browsers liefern dagegen
"yyyy-mm-dd" bzw. "hh:mm" - umgerechnet wird erst beim Speichern. */
import { parseGermanDate, formatTime, dayKey } from './planboard'
// Service Type: gespeichert werden die bisherigen Werte, angezeigt wird Deutsch
export const SERVICE_TYPES = [
{ value: 'Remote', label: 'Fernwartung (remote)' },
{ value: 'On Site', label: 'Vor Ort beim Kunden' },
]
// Kurzform fuer Badges und Listen. Das alte "Off Site" meinte ebenfalls Arbeit aus der Ferne.
const SERVICE_TYPE_SHORT = {
remote: 'Fernwartung',
'off site': 'Fernwartung',
'on site': 'Vor Ort',
comment: 'Kommentar',
}
export function serviceTypeLabel(value) {
return SERVICE_TYPE_SHORT[String(value || '').trim().toLowerCase()] || value
}
// Git-Pushes legt das Kundenportal als Arbeitsblatt an (employeeId "gitea"): Ende = Push,
// der Beginn wird im Ticketsystem nachgetragen
export function isGitWorksheet(worksheet) {
return worksheet?.serviceType === 'GIT'
}
export function needsTimeEntry(worksheet) {
return isGitWorksheet(worksheet) && (!worksheet.startTime || !Number(worksheet.totalTime))
}
const sameName = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase()
/* Wer hat das Arbeitsblatt erstellt? Normale Eintraege tragen die Appwrite-ID des
Mitarbeiters. Bei Git-Pushes steht in employeeName der Gitea-Name des Pushers
(Benutzername wie "knso" oder voller Name); er gehoert dem Mitarbeiter mit diesem
Kuerzel oder Namen. employee ist der eigene Eintrag aus "employees". */
export function isWorksheetCreator(worksheet, user, employee) {
if (!user?.$id || !worksheet) return false
if (!isGitWorksheet(worksheet)) return worksheet.employeeId === user.$id
const pusher = String(worksheet.employeeName || '').trim()
return Boolean(pusher) && [employee?.shortcode, employee?.displayName, user.name].some((name) => sameName(name, pusher))
}
// Zeiten darf nur aendern, wer das Arbeitsblatt erstellt hat. Reine Kommentare haben
// keine Arbeitszeit; Git-Pushes sind Kommentare, bis ihre Zeit nachgetragen ist.
export function canEditWorksheetTimes(worksheet, user, employee) {
return isWorksheetCreator(worksheet, user, employee) && (isGitWorksheet(worksheet) || !worksheet.isComment)
}
const pad = (n) => String(n).padStart(2, '0')
// Heutiges Datum in Ortszeit als "yyyy-mm-dd" (Wert fuer <input type="date">)
export function todayIso(now = new Date()) {
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
// "2026-09-26" -> "26.09.2026"
export function isoToGermanDate(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
}
// "09:30" -> "0930"
export function timeToWoms(value) {
const m = /^(\d{1,2}):(\d{2})/.exec(String(value || ''))
return m ? `${m[1].padStart(2, '0')}${m[2]}` : ''
}
// Rueckweg fuers Bearbeiten: "26.09.2026" (auch "2.5.2026") -> "2026-09-26", sonst ""
export function germanDateToIso(value) {
const date = parseGermanDate(value)
return date ? dayKey(date) : ''
}
// "0930" -> "09:30", sonst ""
export function womsTimeToInput(value) {
return formatTime(value)
}
// Minuten von "hh:mm" bis "hh:mm"; liegt das Ende davor, geht es ueber Mitternacht
export function minutesBetween(start, end) {
const a = /^(\d{1,2}):(\d{2})/.exec(String(start || ''))
const b = /^(\d{1,2}):(\d{2})/.exec(String(end || ''))
if (!a || !b) return null
const diff = Number(b[1]) * 60 + Number(b[2]) - (Number(a[1]) * 60 + Number(a[2]))
return diff < 0 ? diff + 24 * 60 : diff
}
/* Vorschlaege fuer "Requested by" / "Requested for": Kunden, Mitarbeiter
und Namen aus frueheren Tickets. Pro Name (ohne Gross-/Kleinschreibung)
ein Eintrag; customerIds merkt sich, zu welchen Kunden er gehoert. */
export function buildRequesterSuggestions({ customers = [], employees = [], tickets = [] }) {
const byKey = new Map()
const add = (value, { hint = '', search = '', customerId = '', used = false } = {}) => {
const text = String(value || '').trim()
if (!text) return
const key = text.toLowerCase()
let entry = byKey.get(key)
if (!entry) {
entry = { value: text, hint: '', search: key, customerIds: new Set(), uses: 0 }
byKey.set(key, entry)
}
if (hint && !entry.hint) entry.hint = hint
if (search) entry.search += ` ${search.toLowerCase()}`
if (customerId) entry.customerIds.add(customerId)
if (used) entry.uses += 1
}
for (const customer of customers) {
add(customer.name || customer.companyName, {
hint: customer.email ? `Kunde · ${customer.email}` : 'Kunde',
search: customer.email || '',
customerId: customer.$id,
})
}
for (const employee of employees) {
add(employee.displayName, { hint: 'Mitarbeiter', search: employee.email || '' })
}
for (const ticket of tickets) {
add(ticket.requestedBy, { customerId: ticket.customerId, used: true })
add(ticket.requestedFor, { customerId: ticket.customerId, used: true })
}
for (const entry of byKey.values()) {
if (!entry.hint) entry.hint = 'aus früheren Tickets'
}
return [...byKey.values()]
}
/* Passende Vorschlaege zur Eingabe, beste zuerst. Namen des gewaehlten
Kunden stehen oben; ohne Eingabe gibt es nur diese und schon benutzte
Namen, damit die Liste nicht einfach alle Kunden aufzaehlt. */
export function rankSuggestions(entries, query, { customerId = '', limit = 8 } = {}) {
const q = String(query || '').trim().toLowerCase()
const ranked = []
for (const entry of entries) {
const value = entry.value.toLowerCase()
if (q && value === q) continue
const forCustomer = Boolean(customerId) && entry.customerIds.has(customerId)
let score
if (!q) {
if (!forCustomer && !entry.uses) continue
score = 0
} else if (value.startsWith(q)) {
score = 3
} else if (value.split(/[\s.@-]+/).some((word) => word.startsWith(q))) {
score = 2
} else if (entry.search.includes(q)) {
score = 1
} else {
continue
}
if (forCustomer) score += 2
score += Math.min(entry.uses, 10) / 10
ranked.push({ entry, score })
}
ranked.sort((a, b) => b.score - a.score || a.entry.value.localeCompare(b.entry.value, 'de'))
return ranked.slice(0, limit).map(({ entry }) => entry)
}
+456
View File
@@ -0,0 +1,456 @@
.admin-page {
--admin-line: rgba(255, 255, 255, 0.1);
--admin-line-strong: rgba(255, 255, 255, 0.17);
max-width: 1480px;
padding-bottom: 48px;
}
.admin-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.admin-header h1 {
margin: 4px 0 6px;
font-size: clamp(28px, 3vw, 38px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.admin-header p,
.admin-current-user span,
.admin-section-head span {
color: var(--text-muted);
font-size: 13px;
}
.admin-eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.admin-current-user {
min-width: 220px;
padding-left: 16px;
border-left: 2px solid var(--accent);
}
.admin-current-user span,
.admin-current-user strong {
display: block;
}
.admin-current-user strong {
margin-top: 3px;
font-size: 14px;
}
.admin-tabs {
display: flex;
gap: 0;
margin-bottom: 22px;
border-bottom: 1px solid var(--admin-line);
}
.admin-tabs button {
position: relative;
padding: 12px 18px;
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 14px;
font-weight: 650;
cursor: pointer;
}
.admin-tabs button::after {
content: '';
position: absolute;
right: 18px;
bottom: -1px;
left: 18px;
height: 2px;
background: transparent;
}
.admin-tabs button:hover,
.admin-tabs button.active {
color: var(--text);
}
.admin-tabs button.active::after {
background: var(--accent);
}
.admin-notice {
margin-bottom: 16px;
padding: 11px 14px;
border: 1px solid var(--admin-line);
border-left-width: 3px;
background: var(--surface-1);
font-size: 14px;
}
.admin-notice.success { border-left-color: var(--ok); }
.admin-notice.error { border-left-color: var(--danger); }
.admin-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-config-card,
.admin-form-card,
.admin-list-card {
border: 1px solid var(--admin-line);
background: var(--surface-1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.admin-config-card.priorities { grid-column: 1 / -1; }
.admin-section-head {
display: flex;
min-height: 62px;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--admin-line);
}
.admin-section-head h2,
.admin-section-head h3 {
margin: 0 0 3px;
font-size: 15px;
line-height: 1.3;
}
.admin-config-list { padding: 10px; }
.admin-config-row,
.admin-priority-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
padding: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.admin-priority-row { grid-template-columns: 90px minmax(0, 1fr) auto; }
.admin-config-row:last-child,
.admin-priority-row:last-child { border-bottom: 0; }
.admin-icon-button {
display: inline-flex;
min-width: 34px;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid var(--admin-line-strong);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.admin-icon-button:hover {
border-color: rgba(16, 185, 129, 0.5);
color: var(--text);
background: rgba(16, 185, 129, 0.07);
}
.admin-icon-button.danger:hover {
border-color: rgba(239, 68, 68, 0.55);
color: #fca5a5;
background: rgba(239, 68, 68, 0.08);
}
.admin-savebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
padding: 13px 16px;
border: 1px solid var(--admin-line);
background: var(--surface-1);
}
.admin-savebar span { color: var(--text-muted); font-size: 13px; }
.admin-primary-button {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 15px;
border: 1px solid #34d399;
border-radius: 6px;
background: var(--accent);
color: #05251c;
font: inherit;
font-size: 13px;
font-weight: 750;
cursor: pointer;
}
.admin-primary-button:hover { background: #34d399; }
.admin-primary-button:disabled { opacity: 0.55; cursor: wait; }
.admin-split-layout {
display: grid;
grid-template-columns: minmax(290px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.admin-form-card {
position: sticky;
top: 20px;
padding-bottom: 16px;
}
.admin-form-card > .admin-field,
.admin-form-card > .admin-checkbox,
.admin-form-card > .admin-primary-button {
margin-right: 16px;
margin-left: 16px;
}
.admin-field {
display: block;
margin-top: 13px;
}
.admin-field > span {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 650;
}
.admin-field em {
color: var(--text-faint);
font-style: normal;
font-weight: 500;
}
.admin-form-card > .admin-primary-button {
width: calc(100% - 32px);
margin-top: 16px;
}
.admin-checkbox {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 16px;
padding: 11px;
border: 1px solid var(--admin-line);
cursor: pointer;
}
.admin-checkbox input { margin-top: 2px; accent-color: var(--accent); }
.admin-checkbox span,
.admin-checkbox strong,
.admin-checkbox small { display: block; }
.admin-checkbox strong { font-size: 13px; }
.admin-checkbox small { margin-top: 3px; color: var(--text-muted); line-height: 1.35; }
.admin-checkbox.disabled { cursor: not-allowed; opacity: 0.65; }
.admin-list-card { min-width: 0; }
.admin-table-wrap { width: 100%; overflow-x: auto; }
.admin-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.admin-table th {
padding: 10px 13px;
border-bottom: 1px solid var(--admin-line);
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
}
.admin-table td {
padding: 12px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.065);
color: var(--text);
font-size: 13px;
vertical-align: middle;
}
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table tbody tr:hover td { background: rgba(255, 255, 255, 0.018); }
.admin-table td > strong,
.admin-table td > span { display: block; }
.admin-table td > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
.admin-row-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.admin-status,
.admin-role {
display: inline-flex !important;
width: fit-content;
align-items: center;
padding: 3px 7px;
border: 1px solid var(--admin-line-strong);
border-radius: 4px;
color: var(--text-muted) !important;
font-size: 11px !important;
font-weight: 700;
}
.admin-status.active,
.admin-role.admin {
border-color: rgba(16, 185, 129, 0.45);
color: #6ee7b7 !important;
}
.admin-shortcode {
color: var(--text) !important;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px !important;
letter-spacing: 0.04em;
}
.credential {
display: flex;
max-width: 300px;
align-items: center;
gap: 5px;
margin: 2px 0;
}
.credential-value {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
}
.credential-value.is-masked {
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.08em;
}
.credential-action {
display: inline-flex;
width: 25px;
height: 25px;
flex: 0 0 25px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.credential-action:hover { background: var(--surface-2); color: var(--text); }
.credential-check { color: var(--ok); }
.credential-empty { color: var(--text-faint); }
.admin-credential-result {
display: grid;
grid-template-columns: minmax(190px, 1fr) minmax(180px, auto) minmax(180px, auto) auto;
gap: 16px;
align-items: center;
margin-bottom: 16px;
padding: 13px 15px;
border: 1px solid rgba(16, 185, 129, 0.42);
background: rgba(16, 185, 129, 0.065);
}
.admin-credential-result strong,
.admin-credential-result span { display: block; }
.admin-credential-result span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
.admin-loading,
.admin-empty-state,
.admin-empty-line {
padding: 28px 18px;
color: var(--text-muted);
text-align: center;
}
.admin-loading .spinner { width: 22px; height: 22px; margin-bottom: 9px; }
.admin-empty-state h3 { margin-bottom: 5px; color: var(--text); font-size: 15px; }
.admin-empty-state p { font-size: 13px; }
.admin-empty-line { padding: 16px 8px; font-size: 13px; }
.admin-access-denied {
max-width: 540px;
margin: 12vh auto 0;
padding: 32px;
border-left: 3px solid var(--danger);
background: var(--surface-1);
}
.admin-access-denied > span { color: var(--danger); font-size: 12px; font-weight: 800; letter-spacing: 0.1em; }
.admin-access-denied h1 { margin: 8px 0; }
.admin-access-denied p { color: var(--text-muted); }
.spin { animation: admin-spin 0.8s linear infinite; }
@keyframes admin-spin { to { transform: rotate(360deg); } }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1060px) {
.admin-split-layout { grid-template-columns: 1fr; }
.admin-form-card { position: static; }
.admin-credential-result { grid-template-columns: 1fr auto; }
}
@media (max-width: 760px) {
.admin-page { padding: 16px; }
.admin-header { align-items: flex-start; flex-direction: column; }
.admin-current-user { width: 100%; }
.admin-tabs { overflow-x: auto; }
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
.admin-config-grid { grid-template-columns: 1fr; }
.admin-config-card.priorities { grid-column: auto; }
.admin-savebar { align-items: stretch; flex-direction: column; }
.admin-credential-result { grid-template-columns: 1fr; }
}
+440 -637
View File
File diff suppressed because it is too large. Load diff
+20 -27
View File
@@ -1,47 +1,40 @@
import { useState } from 'react'
import { FaServer, FaDesktop, FaPrint, FaNetworkWired } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function AssetsPage() {
const [searchTerm, setSearchTerm] = useState('')
// Placeholder data - would come from Appwrite in production
const assetCategories = [
{ icon: FaServer, name: 'Servers', count: 0 },
{ icon: FaDesktop, name: 'Workstations', count: 0 },
{ icon: FaPrint, name: 'Printers', count: 0 },
{ icon: FaNetworkWired, name: 'Network Devices', count: 0 }
{ icon: FaServer, name: 'Server', count: 0 },
{ icon: FaDesktop, name: 'Arbeitsplaetze', count: 0 },
{ icon: FaPrint, name: 'Drucker', count: 0 },
{ icon: FaNetworkWired, name: 'Netzwerkgeraete', count: 0 },
]
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Assets Management</h2>
</header>
<div className="page">
<PageHeader title="Assets" subtitle="IT-Inventar und Geraete" />
<div className="search-bar mb-2">
<input
type="text"
placeholder="Search assets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input"
style={{ width: '100%', maxWidth: '400px' }}
/>
</div>
<input
className="form-control"
placeholder="Assets suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ maxWidth: 400, marginBottom: 16 }}
/>
<div className="assets-grid">
<div className="kpi-grid">
{assetCategories.map(({ icon: Icon, name, count }) => (
<div key={name} className="asset-card">
<Icon size={48} className="text-teal" />
<h4>{name}</h4>
<span className="asset-count">{count}</span>
<div key={name} className="kpi">
<span className="kpi-accent" />
<div className="kpi-label"><Icon /> {name}</div>
<div className="kpi-value">{count}</div>
</div>
))}
</div>
<div className="text-center mt-4 text-grey">
<p>Asset management module - Connect to Appwrite to manage your IT assets.</p>
</div>
<Card><p className="muted" style={{ margin: 0 }}>Asset-Modul - mit Appwrite verbinden, um IT-Assets zu verwalten.</p></Card>
</div>
)
}
+343
View File
@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
import InvoicePanel from '../components/InvoicePanel'
import DocumentsPanel from '../components/DocumentsPanel'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
const PORTAL_URL = 'https://project.webklar.com'
const TABS = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'projects', label: 'Projekte' },
]
export default function CustomerDetailPage() {
const { id } = useParams()
const [customer, setCustomer] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tab, setTab] = useState('overview')
const load = useCallback(async () => {
setLoading(true)
try {
setCustomer(await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id))
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurueck</Button>} /></div>
const stage = customerStage(customer)
const archived = isArchived(customer)
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
await load()
}
return (
<div className="page">
<PageHeader
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
{customer.name}
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{archived && <Badge tone="muted"><FaBoxArchive /> Archiviert</Badge>}
</span>
}
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
actions={
<>
<Button variant="ghost" onClick={toggleArchived}>
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
</Button>
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
</>
}
/>
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{tab === 'invoices' && (
stage === 'lead' ? (
<Card><EmptyState title="Noch ein Lead" hint="Erst zum festen Kunden machen (vollstaendige Daten noetig), dann sind Rechnungen moeglich." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten erfassen</Button>} /></Card>
) : !isInvoiceReady(customer) ? (
<Card><EmptyState title="Daten unvollstaendig" hint="Fuer Rechnungen werden Unternehmen, E-Mail, Strasse, Stadt und PLZ benoetigt." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten vervollstaendigen</Button>} /></Card>
) : (
<InvoicePanel ticket={syntheticTicket} />
)
)}
{tab === 'documents' && <DocumentsPanel ticket={syntheticTicket} initialScope="customer" />}
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
</div>
)
}
function OverviewTab({ customer, stage, onSaved }) {
const [form, setForm] = useState({
name: customer.name || '',
companyName: customer.companyName || customer.name || '',
code: customer.code || '',
email: customer.email || '',
phone: customer.phone || '',
contactName: customer.contactName || '',
street: customer.street || '',
postalCode: customer.postalCode || '',
city: customer.city || '',
country: customer.country || 'Deutschland',
vatNumber: customer.vatNumber || '',
location: customer.location || '',
notes: customer.notes || '',
importantCustomer: !!customer.importantCustomer,
paperlessCorrespondent: customer.paperlessCorrespondent || '',
})
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const ready = isInvoiceReady({ ...customer, ...form })
const save = async () => {
setBusy(true); setMsg(null)
try {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
let note = 'Gespeichert'
// Wenn fester Kunde: Aenderungen nach InvoiceNinja syncen (sofern Daten reichen oder bereits verknuepft)
if (stage === 'customer' && (updated.invoiceNinjaClientId || isInvoiceReady(updated))) {
try { await syncCustomerToNinja(updated) } catch (e) { note = 'Gespeichert, InvoiceNinja-Sync fehlgeschlagen: ' + e.message }
}
setMsg(note)
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
} finally { setBusy(false) }
}
const upgrade = async () => {
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
setBusy(true); setMsg(null)
const r = await upgradeToCustomer(customer, form)
if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.')
else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt'))
setBusy(false)
onSaved()
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
<Card title="Stammdaten">
<div className="flex gap-3 wrap">
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
</div>
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
Wichtiger Kunde
</label>
<div className="flex gap-2 items-center wrap">
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
{msg && <span className="muted">{msg}</span>}
</div>
</Card>
<Card title="Rechnungsdaten (fuer InvoiceNinja)">
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollstaendig - fuer Rechnungen erforderlich</div>}
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
<div className="flex gap-3 wrap">
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
</div>
<div className="flex items-center justify-between" style={{ marginTop: 8 }}>
<span className="muted">InvoiceNinja</span>
{customer.invoiceNinjaClientId ? <Badge tone="ok" dot>verknuepft</Badge> : <Badge tone="muted">nicht verknuepft</Badge>}
</div>
<Field label="Paperless-Korrespondent" hint="Leer = Kundenname wird verwendet">
<input className="form-control" value={form.paperlessCorrespondent} onChange={(e) => setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
</Field>
</Card>
<PortalAccessCard customer={customer} onSaved={onSaved} />
</div>
)
}
function PortalAccessCard({ customer, onSaved }) {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
const currentPassword = getCustomerPortalPassword(customer)
const savePassword = async () => {
if (!customer.email) {
setMsg('Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.')
return
}
if (!password || password.length < 8) {
setMsg('Das Passwort muss mindestens 8 Zeichen haben.')
return
}
setBusy(true); setMsg(null)
try {
await updateCustomerWithPortalAccess(customer.$id, { password })
setPassword('')
setMsg(hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.')
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
} finally {
setBusy(false)
}
}
return (
<Card title="Portal-Zugang">
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
<span className="muted">Kundenportal</span>
{hasAccess
? <Badge tone="ok" dot>freigeschaltet</Badge>
: <Badge tone="muted">kein Zugang</Badge>}
</div>
<Field label="Login (E-Mail)">
<CopyableCredential value={customer.email} label="Login" />
</Field>
<Field label="Passwort">
<CopyableCredential value={currentPassword} secret label="Passwort" />
</Field>
<Field label={hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}>
<input
className="form-control"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="min. 8 Zeichen"
autoComplete="off"
/>
</Field>
<div className="flex gap-2 items-center wrap">
<Button variant="primary" onClick={savePassword} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
</Button>
<Button as="a" href={PORTAL_URL} target="_blank" rel="noreferrer" variant="ghost">
<FaArrowUpRightFromSquare /> Portal öffnen
</Button>
{msg && <span className="muted">{msg}</span>}
</div>
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
und sieht dort seine Projekte/Previews.
</p>
</Card>
)
}
function CustomerTickets({ customerId }) {
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(100),
])
if (active) setTickets(res.documents)
} catch {
if (active) setTickets([])
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [customerId])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
return (
<Card pad={false}>
<div className="list">
{tickets.map((t) => (
<div key={t.$id} className="list-row">
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
<div className="grow">
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
<div className="muted">{t.type}</div>
</div>
<PriorityPill priority={t.priority} />
<StatusPill status={t.status} />
</div>
))}
</div>
</Card>
)
}
function CustomerProjects({ customerId }) {
const { fetchAllProjects } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
const all = await fetchAllProjects()
if (active) setProjects((all || []).filter((p) => p.customerId === customerId))
if (active) setLoading(false)
})()
return () => { active = false }
}, [customerId, fetchAllProjects])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
return (
<Card title={`Projekte (${projects.length})`}>
{projects.map((p) => <AssignedProjectCard key={p.$id} project={p} />)}
</Card>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead, customerStage, isArchived } from '../lib/leads'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
export default function CustomersPage() {
const { customers, loading, refresh } = useCustomers()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [stage, setStage] = useState('all') // all | lead | customer | archived
const [showCreate, setShowCreate] = useState(false)
const filtered = useMemo(() => {
return (customers || [])
.filter((c) => {
const archived = isArchived(c)
// Archivierte nur im Archiviert-Tab, sonst nie
if (stage === 'archived') return archived
if (archived) return false
const st = customerStage(c)
if (st === 'lost') return stage === 'all'
if (stage === 'all') return true
return st === stage
})
.filter((c) => {
if (!search) return true
const s = search.toLowerCase()
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
})
}, [customers, search, stage])
return (
<div className="page">
<PageHeader
title="Kunden"
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja"
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Lead</Button>}
/>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateLeadForm
onCancel={() => setShowCreate(false)}
onDone={async (data) => {
const r = await createLead(data, user)
setShowCreate(false)
refresh()
return r
}}
/>
</div>
)}
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
</div>
<input className="form-control search-input" placeholder="Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Eintraege" hint="Lege einen neuen Lead an." />
) : (
<div className="list">
{filtered.map((c) => {
const st = customerStage(c)
return (
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
{(c.name || '?').charAt(0).toUpperCase()}
</div>
<div className="grow">
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
{c.name}
{c.code && <span className="faint" style={{ fontWeight: 400 }}>· {c.code}</span>}
</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
</div>
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
{isArchived(c) && <Badge tone="muted">Archiviert</Badge>}
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{c.invoiceNinjaClientId && <Badge tone="info"><FaFileInvoiceDollar /> Ninja</Badge>}
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Docs</Badge>}
</div>
</Link>
)
})}
</div>
)}
</div>
</Card>
</div>
)
}
function CreateLeadForm({ onCancel, onDone }) {
const [company, setCompany] = useState('')
const [email, setEmail] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
setBusy(true); setError(null)
try {
await onDone({ company: company.trim(), email: email.trim() })
} catch (err) {
setError(err.message || 'Fehler')
setBusy(false)
}
}
return (
<Card title="Neuer Lead (potenzieller Kunde)">
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
</p>
<form onSubmit={submit}>
<div className="flex gap-3 wrap">
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Lead anlegen + Akquise-Ticket'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
</Card>
)
}
+81 -49
View File
@@ -1,72 +1,104 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck } from 'react-icons/fa6'
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar, FaUpload } from 'react-icons/fa6'
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
export default function DashboardPage() {
const [stats, setStats] = useState({
totalTickets: 0,
openTickets: 0,
closedTickets: 0,
pendingTickets: 0
})
const { summary, loading: finLoading } = useFinanceSummary()
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
const [recent, setRecent] = useState([])
const [docCount, setDocCount] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchStats() {
let active = true
;(async () => {
try {
const [total, open, closed, pending] = await Promise.all([
const [total, open, closed, awaiting, recentRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)])
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
])
setStats({
totalTickets: total.total,
openTickets: open.total,
closedTickets: closed.total,
pendingTickets: pending.total
})
} catch (err) {
console.error('Error fetching stats:', err)
if (!active) return
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
setRecent(recentRes.documents)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
if (active) setLoading(false)
}
}
fetchStats()
try {
const d = await integrationsApi.listDocuments({ page_size: 1 })
if (active) setDocCount(d.count)
} catch { /* ignore */ }
})()
return () => { active = false }
}, [])
const StatCard = ({ icon: Icon, label, value, color }) => (
<div className="stat-card" style={{ borderLeft: `4px solid ${color}` }}>
<div className="stat-icon" style={{ color }}>
<Icon size={32} />
</div>
<div className="stat-info">
<span className="stat-value">{loading ? '...' : value}</span>
<span className="stat-label">{label}</span>
</div>
</div>
)
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Dashboard</h2>
</header>
<div className="page">
<PageHeader
title="Dashboard"
subtitle="Ueberblick ueber Tickets, Finanzen und Dokumente"
actions={
<>
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
</>
}
/>
<div className="stats-grid">
<StatCard icon={FaTicket} label="Total Tickets" value={stats.totalTickets} color="#2196F3" />
<StatCard icon={FaClipboardList} label="Open Tickets" value={stats.openTickets} color="#4CAF50" />
<StatCard icon={FaClock} label="Pending" value={stats.pendingTickets} color="#FF9800" />
<StatCard icon={FaCircleCheck} label="Closed" value={stats.closedTickets} color="#9E9E9E" />
<div className="kpi-grid">
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
</div>
<div className="dashboard-section mt-4">
<h3>Quick Actions</h3>
<div className="quick-actions">
<a href="/tickets" className="btn btn-teal">View All Tickets</a>
<a href="/reports" className="btn btn-dark">Generate Report</a>
</div>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Dokumente" value={docCount ?? '...'} accent="var(--info)" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
<Card title="Letzte Tickets" pad={false}>
{loading ? (
<div className="empty">...</div>
) : recent.length === 0 ? (
<EmptyState title="Keine Tickets" />
) : (
<div className="list">
{recent.map((t) => (
<Link key={t.$id} to="/tickets" className="list-row">
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
<div className="grow">
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
<div className="muted">{t.customerName || '-'}</div>
</div>
<PriorityPill priority={t.priority} />
<StatusPill status={t.status} />
</Link>
))}
</div>
)}
</Card>
<Card title="Schnellaktionen">
<div className="flex flex-col gap-2">
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
<Button as={Link} to="/documents"><FaUpload /> Dokument hochladen</Button>
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
</div>
</Card>
</div>
</div>
)
+14 -53
View File
@@ -1,66 +1,27 @@
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function DocsPage() {
const sections = [
{
icon: FaBook,
title: 'Getting Started',
content: 'Learn how to create and manage work orders in WOMS 2.0.'
},
{
icon: FaCircleQuestion,
title: 'FAQ',
content: 'Frequently asked questions about the system.'
},
{
icon: FaKeyboard,
title: 'Keyboard Shortcuts',
content: 'Speed up your workflow with keyboard shortcuts.'
}
]
const shortcuts = [
{ key: 'N', action: 'New Ticket' },
{ key: 'F', action: 'Focus Search' },
{ key: 'R', action: 'Refresh List' },
{ key: 'Esc', action: 'Close Modal' }
{ icon: FaBook, title: 'Erste Schritte', content: 'Tickets, Rechnungen und Dokumente in der Plattform verwalten.' },
{ icon: FaCircleQuestion, title: 'FAQ', content: 'Haeufige Fragen zum System.' },
{ icon: FaKeyboard, title: 'Tipps', content: 'Schneller arbeiten mit der Plattform.' },
]
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Documentation</h2>
</header>
<div className="docs-grid">
<div className="page">
<PageHeader title="Dokumentation" subtitle="Hilfe und Anleitungen" />
<div className="kpi-grid">
{sections.map(({ icon: Icon, title, content }) => (
<div key={title} className="doc-card">
<Icon size={32} className="text-teal" />
<h4>{title}</h4>
<p className="text-grey">{content}</p>
</div>
<Card key={title}>
<div className="flex items-center gap-3" style={{ marginBottom: 8 }}>
<Icon size={22} style={{ color: 'var(--accent)' }} />
<strong>{title}</strong>
</div>
<p className="muted" style={{ margin: 0 }}>{content}</p>
</Card>
))}
</div>
<div className="shortcuts-section mt-4">
<h3 className="text-center">Keyboard Shortcuts</h3>
<table className="table" style={{ maxWidth: '400px', margin: '0 auto' }}>
<thead>
<tr>
<th>Key</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map(({ key, action }) => (
<tr key={key}>
<td><kbd>{key}</kbd></td>
<td>{action}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from 'react'
import { FaFileLines, FaEye, FaDownload, FaSpinner, FaMagnifyingGlass, FaRotate, FaUpload } from 'react-icons/fa6'
import { integrationsApi } from '../lib/integrationsApi'
import { useCustomers } from '../hooks/useCustomers'
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
import FileUploadModal from '../components/FileUploadModal'
export default function DocumentsPage() {
const { customers } = useCustomers()
const [docs, setDocs] = useState([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [query, setQuery] = useState('')
const [correspondent, setCorrespondent] = useState('')
const [busyId, setBusyId] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params = { page_size: 50 }
if (query) params.query = query
if (correspondent) params.correspondent = correspondent
const data = await integrationsApi.listDocuments(params)
setDocs(data.documents || [])
setCount(data.count || 0)
} catch (err) {
setError(err.message)
setDocs([])
} finally {
setLoading(false)
}
}, [query, correspondent])
useEffect(() => { load() }, [load])
const open = async (id, kind) => {
setBusyId(id + kind)
try { await integrationsApi.openDocument(id, kind) }
catch (err) { alert('Fehler: ' + err.message) }
finally { setBusyId(null) }
}
return (
<div className="page">
<PageHeader
title="Dokumente"
subtitle="Alle Belege und Kundendokumente aus Paperless-ngx"
actions={
<>
<Button variant="ghost" onClick={load}><FaRotate /> Aktualisieren</Button>
<Button variant="primary" onClick={() => setShowUpload(true)}><FaUpload /> Hochladen</Button>
</>
}
/>
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<select className="form-control" style={{ maxWidth: 240 }} value={correspondent} onChange={(e) => setCorrespondent(e.target.value)}>
<option value="">Alle Kunden</option>
{customers.map((c) => <option key={c.$id} value={c.name}>{c.name}</option>)}
</select>
<div className="flex gap-2 grow" style={{ minWidth: 220 }}>
<input className="form-control" placeholder="Volltextsuche..." value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && load()} />
<Button variant="primary" onClick={load}><FaMagnifyingGlass /></Button>
</div>
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : docs.length === 0 ? (
<EmptyState icon={<FaFileLines />} title="Keine Dokumente" hint="Keine Dokumente gefunden." />
) : (
<>
<div className="faint" style={{ padding: '8px 16px', fontSize: 12 }}>{count} Dokument(e)</div>
<div className="list">
{docs.map((d) => (
<div key={d.id} className="list-row">
<FaFileLines style={{ color: 'var(--accent)' }} />
<div className="grow">
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{d.title}</div>
<div className="muted">{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
</div>
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'preview'} onClick={() => open(d.id, 'preview')} title="Vorschau">
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
</Button>
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'download'} onClick={() => open(d.id, 'download')} title="Download">
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
</Button>
</div>
))}
</div>
</>
)}
</div>
</Card>
<FileUploadModal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
workorderId=""
ticket={correspondent ? { customerName: correspondent, woid: '' } : { customerName: '', woid: '' }}
onUploadComplete={() => setTimeout(load, 1500)}
/>
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { useCustomers } from '../hooks/useCustomers'
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
export default function FinancePage() {
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
const { customers } = useCustomers()
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('all') // all | open | overdue | paid
const [search, setSearch] = useState('')
const [openingId, setOpeningId] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await integrationsApi.listInvoices(undefined, 400)
setInvoices(data.invoices || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const today = new Date().toISOString().slice(0, 10)
const filtered = useMemo(() => {
return invoices.filter((inv) => {
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
if (filter === 'overdue' && !overdue) return false
if (filter === 'paid' && inv.status_id !== 4) return false
if (search) {
const s = search.toLowerCase()
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
}
return true
})
}, [invoices, filter, search, today])
const openPdf = async (id) => {
setOpeningId(id)
try { await integrationsApi.openInvoicePdf(id) }
catch (err) { alert('PDF-Fehler: ' + err.message) }
finally { setOpeningId(null) }
}
return (
<div className="page">
<PageHeader
title="Finanzen"
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
actions={
<>
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> InvoiceNinja
</Button>
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</Button>
</>
}
/>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
</div>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
</div>
)}
<Card pad={false}>
<div className="ui-card-head">
<div className="seg">
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
))}
</div>
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." />
) : (
<div style={{ overflowX: 'auto' }}>
<table className="ui-table">
<thead>
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{filtered.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '—', color: 'var(--text-muted)' }
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
return (
<tr key={inv.id}>
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
<td>{inv.client_name || '-'}</td>
<td className="muted">{inv.date || '-'}</td>
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
<td>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (ueberf.)' : ''}</span></td>
<td>
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
)
}
function CreateInvoiceForm({ customers, onDone, onCancel }) {
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
const [clientId, setClientId] = useState('')
const [description, setDescription] = useState('')
const [cost, setCost] = useState('')
const [quantity, setQuantity] = useState('1')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!clientId) { setError('Bitte Kunde waehlen'); return }
setBusy(true); setError(null)
try {
await integrationsApi.createInvoice({
client_id: clientId,
public_notes: description,
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
})
onDone()
} catch (err) { setError(err.message); setBusy(false) }
}
return (
<Card title="Neue Rechnung">
{linkable.length === 0 ? (
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
) : (
<form onSubmit={submit}>
<Field label="Kunde">
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
<option value="">Kunde waehlen...</option>
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
</select>
</Field>
<Field label="Leistung / Beschreibung">
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
</Field>
<div className="flex gap-3">
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
)}
</Card>
)
}
+640
View File
@@ -0,0 +1,640 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
FaSpinner,
FaStar,
FaGear,
FaPlus,
FaXmark,
FaMapLocationDot,
FaGlobe,
FaPhone,
FaEnvelope,
FaChevronDown,
FaChevronUp,
FaGitAlt,
FaFloppyDisk,
FaArrowsRotate,
FaCheck,
FaCopy,
FaEye,
FaHashtag,
FaTriangleExclamation,
} from 'react-icons/fa6'
import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads'
import { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui'
import CopyableCredential from '../components/CopyableCredential'
// Schritte der Server-Pipeline (processor.py) — Reihenfolge = pipelineStep 1..5
const PIPELINE_STEPS = [
'Lead erstellt',
'Kunde & Repo',
'Daten eingesetzt',
'Personalisiert',
'E-Mail erstellt',
]
const pulseStyle = `
@keyframes leadPulse { 0% { opacity: .35 } 50% { opacity: 1 } 100% { opacity: .35 } }
`
const STATUS_OPTIONS = [
{ id: 'neu', label: 'Neu', tone: 'info' },
{ id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' },
{ id: 'interessiert', label: 'Interessiert', tone: 'warn' },
{ id: 'gewonnen', label: 'Gewonnen', tone: 'ok' },
{ id: 'abgelehnt', label: 'Abgelehnt', tone: 'danger' },
]
const statusMeta = (id) => STATUS_OPTIONS.find((s) => s.id === id) || STATUS_OPTIONS[0]
function scoreColor(score) {
if (score >= 85) return '#34d399'
if (score >= 70) return '#fbbf24'
return '#f87171'
}
/** Balken ganz oben: laeuft von Minute 0 bis 5 (lastRunAt -> nextRunAt) und
* zeigt, wann der Server das naechste Mal nach neuen Leads schaut. Rechts
* ein Refresh-Icon, das den Lauf sofort ausloest (leadSettings.runNow). */
function CycleBar() {
const { cycle, triggering, triggerNow } = useLeadCycle()
const [now, setNow] = useState(Date.now())
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const last = cycle?.lastRunAt ? new Date(cycle.lastRunAt).getTime() : null
const next = cycle?.nextRunAt ? new Date(cycle.nextRunAt).getTime() : null
const active = Boolean(cycle?.workerBusy || cycle?.runNow || triggering)
let pct = 0
let label = 'Automatik-Status unbekannt'
if (active) {
pct = 100
label = 'Pipeline läuft — Leads werden verarbeitet…'
} else if (last && next && next > last) {
pct = Math.min(100, Math.max(0, ((now - last) / (next - last)) * 100))
const rest = Math.max(0, next - now)
const m = Math.floor(rest / 60000)
const s = Math.floor((rest % 60000) / 1000)
label = rest > 0
? `Nächste Prüfung auf neue Leads in ${m}:${String(s).padStart(2, '0')} Min.`
: 'Prüfung startet gleich…'
}
return (
<div className="ui-card pad" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
<div className="grow" style={{ minWidth: 0 }}>
<div className="flex" style={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>Lead-Automatik (alle 5 Minuten)</div>
<div className="muted" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{label}</div>
</div>
<div style={{ height: 8, borderRadius: 4, background: 'var(--surface-2)', overflow: 'hidden', marginTop: 6 }}>
<div
style={{
width: `${pct}%`,
height: '100%',
background: active ? '#3b82f6' : 'var(--accent, #3b82f6)',
transition: 'width 1s linear',
animation: active ? 'leadPulse 1.2s ease-in-out infinite' : 'none',
}}
/>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={triggerNow}
disabled={active}
title="Jetzt sofort auf neue Leads prüfen und verarbeiten"
aria-label="Pipeline jetzt starten"
>
<FaArrowsRotate className={active ? 'spinner' : ''} />
</Button>
</div>
)
}
export default function LeadsPage() {
const { leads, pipelines, loading, error } = useLeads()
const [search, setSearch] = useState('')
const [status, setStatus] = useState('all')
const [showSettings, setShowSettings] = useState(false)
const filtered = useMemo(() => {
return (leads || [])
.filter((l) => (status === 'all' ? true : (l.crmStatus || 'neu') === status))
.filter((l) => {
if (!search) return true
const s = search.toLowerCase()
return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s)
})
}, [leads, search, status])
const kpis = useMemo(() => {
const all = leads || []
const neu = all.filter((l) => (l.crmStatus || 'neu') === 'neu').length
const gewonnen = all.filter((l) => l.crmStatus === 'gewonnen').length
const avg = all.length
? Math.round(all.reduce((sum, l) => sum + (l.leadScore || 0), 0) / all.length)
: 0
const orte = new Set(all.map((l) => (l.ort || '').trim().toLowerCase()).filter(Boolean))
return { total: all.length, neu, gewonnen, avg, orte: orte.size }
}, [leads])
return (
<div className="page">
<style>{pulseStyle}</style>
<PageHeader
title="Leads"
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — hier nur Anzeige & Verfolgung, gearbeitet wird im Akquise-Ticket"
actions={
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
<FaGear /> Sucheinstellungen
</Button>
}
/>
<CycleBar />
{showSettings && (
<div style={{ marginBottom: 16 }}>
<LeadSettingsCard onClose={() => setShowSettings(false)} />
</div>
)}
<div className="kpi-grid" style={{ marginBottom: 16 }}>
<Kpi label="Leads gesamt" value={kpis.total} />
<Kpi label="Neu" value={kpis.neu} accent="#3b82f6" />
<Kpi label="Gewonnen" value={kpis.gewonnen} accent="#10b981" />
<Kpi label="Ø Lead-Score" value={kpis.avg} accent="#f59e0b" />
<Kpi label="Orte" value={kpis.orte} accent="#8b5cf6" />
</div>
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={status === 'all' ? 'active' : ''} onClick={() => setStatus('all')}>Alle</button>
{STATUS_OPTIONS.map((s) => (
<button key={s.id} className={status === s.id ? 'active' : ''} onClick={() => setStatus(s.id)}>
{s.label}
</button>
))}
</div>
<input
className="form-control search-input"
placeholder="Lead suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: 320 }}
/>
</div>
<div className="ui-card-body">
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<EmptyState title="Fehler beim Laden" hint={error} />
) : filtered.length === 0 ? (
<EmptyState
title="Keine Leads"
hint="Die 06:00-Routine liefert neue Leads automatisch hierher."
/>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 14,
}}
>
{filtered.map((lead) => (
<LeadCard key={lead.$id} lead={lead} pipeline={pipelines[lead.leadId]} />
))}
</div>
)}
</div>
</Card>
</div>
)
}
/** Verfolgbarer Pipeline-Balken (5 Segmente) — zeigt pro Lead, welcher
* Automatik-Schritt erledigt ist, gerade laeuft oder fehlgeschlagen ist. */
function PipelineBar({ lead }) {
const step = Number.isInteger(lead.pipelineStep) ? lead.pipelineStep : null
if (step === null) return null
const status = lead.pipelineStatus || 'idle'
let label
if (status === 'done') label = 'Automatisch verarbeitet ✓'
else if (status === 'running') label = `${PIPELINE_STEPS[step] || 'Verarbeitung'} läuft…`
else if (status === 'pending') label = 'Wartet auf den nächsten Lauf'
else if (status === 'failed') label = `Fehler: ${PIPELINE_STEPS[step] || '?'}`
else if (status === 'waiting_enrichment') label = 'Wartet auf Anreicherung (Bilder & Logo)'
else label = step > 0 ? `Stand: ${PIPELINE_STEPS[step - 1]} (manuell)` : 'Keine Automatik'
return (
<div>
<div style={{ display: 'flex', gap: 3 }}>
{PIPELINE_STEPS.map((name, i) => {
const n = i + 1
let background = 'var(--surface-2)'
let animation = 'none'
if (n <= step) background = '#34d399'
if (status === 'running' && n === step + 1) {
background = '#3b82f6'
animation = 'leadPulse 1.2s ease-in-out infinite'
}
if (status === 'failed' && n === step + 1) background = '#f87171'
if (status === 'waiting_enrichment' && n === step + 1) background = '#fbbf24'
if (status === 'idle' && n <= step) background = '#9ca3af'
return (
<div
key={name}
title={`${n}. ${name}`}
style={{ flex: 1, height: 6, borderRadius: 3, background, animation }}
/>
)
})}
</div>
<div
className={status === 'failed' ? 'text-red' : 'faint'}
title={status === 'failed' ? lead.pipelineError || '' : label}
style={{ fontSize: 11, marginTop: 3, display: 'flex', alignItems: 'center', gap: 4 }}
>
{status === 'failed' && <FaTriangleExclamation />}
{label} · {Math.min(step, PIPELINE_STEPS.length)}/{PIPELINE_STEPS.length}
</div>
</div>
)
}
/** Schritt-Log aus leadPipeline.steps (JSON) — im aufgeklappten Bereich. */
function PipelineLog({ pipeline }) {
const steps = useMemo(() => {
try {
const parsed = JSON.parse(pipeline?.steps || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}, [pipeline])
if (steps.length === 0) return null
return (
<div>
<strong>Pipeline-Verlauf:</strong>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
{steps.map((s) => (
<div key={s.n} className="flex gap-2" style={{ alignItems: 'baseline', fontSize: 12 }}>
<span style={{ color: s.ok ? '#34d399' : '#f87171', flexShrink: 0 }}>
{s.ok ? <FaCheck /> : <FaXmark />}
</span>
<span style={{ flexShrink: 0 }}>{s.n}. {s.name}</span>
<span className="faint" style={{ flexShrink: 0 }}>
{s.at ? new Date(s.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
{s.hint && <span className="muted">— {s.hint}</span>}
</div>
))}
</div>
</div>
)
}
/** Der von der Pipeline geschriebene E-Mail-Entwurf, mit Kopieren-Button. */
function EmailDraft({ pipeline }) {
const [copied, setCopied] = useState(false)
if (!pipeline?.emailText) return null
const copy = async () => {
try {
await navigator.clipboard.writeText(
`Betreff: ${pipeline.emailBetreff || ''}\n\n${pipeline.emailText}`
)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch { /* Clipboard nicht verfuegbar */ }
}
return (
<div>
<div className="flex gap-2" style={{ alignItems: 'center' }}>
<strong>E-Mail-Entwurf:</strong>
<Button size="sm" variant="ghost" onClick={copy} title="E-Mail in die Zwischenablage kopieren">
{copied ? <FaCheck /> : <FaCopy />}
</Button>
</div>
{pipeline.emailBetreff && (
<div style={{ fontWeight: 600, fontSize: 12, marginTop: 4 }}>{pipeline.emailBetreff}</div>
)}
<div
className="muted"
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginTop: 4, maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6, padding: 8 }}
>
{pipeline.emailText}
</div>
</div>
)
}
/** Reine Info-Anzeige — Status/Notizen werden NICHT hier gepflegt,
* sondern im Akquise-Ticket des Leads (WOID-Chip). */
function LeadCard({ lead, pipeline }) {
const [open, setOpen] = useState(false)
const meta = statusMeta(lead.crmStatus || 'neu')
const score = lead.leadScore || 0
const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null
return (
<div className="ui-card pad" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div className="grow" style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{lead.firma}
{lead.kategorie && <Badge tone={lead.kategorie === 'A' ? 'ok' : 'muted'}>Kat. {lead.kategorie}</Badge>}
</div>
<div className="muted">
{[lead.ort, lead.branche].filter(Boolean).join(' · ')}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontWeight: 800, fontSize: 20, color: scoreColor(score) }}>{score}</div>
<div className="faint" style={{ fontSize: 11 }}>Score</div>
</div>
</div>
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
</div>
<PipelineBar lead={lead} />
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
<Badge tone={meta.tone} dot>{meta.label}</Badge>
{lead.googleNote && (
<Badge tone="warn">
<FaStar /> {lead.googleNote} ({lead.anzahlBewertungen || 0})
</Badge>
)}
{lead.processed && <Badge tone="ok">Kunde angelegt</Badge>}
</div>
{lead.websiteStatus && (
<div className="muted" style={{ fontSize: 13 }}>
<strong>Website:</strong> {lead.websiteStatus.length > 120 && !open
? lead.websiteStatus.slice(0, 120) + '…'
: lead.websiteStatus}
</div>
)}
<div className="flex gap-2 wrap" style={{ fontSize: 13 }}>
{lead.email && (
<a href={`mailto:${lead.email}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FaEnvelope /> {lead.email}
</a>
)}
{lead.telefon && (
<a href={`tel:${lead.telefon.replace(/\s/g, '')}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FaPhone /> {lead.telefon}
</a>
)}
</div>
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
{lead.googleMapsLink && (
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps" aria-label="Google Maps">
<FaMapLocationDot />
</Button>
)}
{websiteUrl && (
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Bestehende Website" aria-label="Bestehende Website">
<FaGlobe />
</Button>
)}
{lead.repoUrl && (
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo (Gitea)" aria-label="Preview-Repo">
<FaGitAlt />
</Button>
)}
{lead.previewUrl && (
<Button size="sm" variant="primary" as="a" href={lead.previewUrl} target="_blank" rel="noreferrer" title="Erstellte Vorschau-Website öffnen (Login-geschützt)" aria-label="Vorschau öffnen">
<FaEye />
</Button>
)}
{lead.woid && (
<Button
size="sm"
variant="ghost"
as={Link}
to={`/tickets?woid=${lead.woid}`}
title={`Akquise-Ticket WOID ${lead.woid} öffnen`}
aria-label={`Ticket WOID ${lead.woid}`}
>
<FaHashtag />{lead.woid}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setOpen((o) => !o)} title={open ? 'Weniger' : 'Details'} style={{ marginLeft: 'auto' }}>
{open ? <FaChevronUp /> : <FaChevronDown />}
</Button>
</div>
{open && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{lead.ansprechpartner && <div><strong>Ansprechpartner:</strong> {lead.ansprechpartner}</div>}
{lead.adresse && <div><strong>Adresse:</strong> {lead.adresse}</div>}
{lead.kernleistungen && <div><strong>Leistungen:</strong> {lead.kernleistungen}</div>}
{lead.hauptmaengel && <div><strong>Hauptmängel:</strong> {lead.hauptmaengel}</div>}
{lead.sympathie && <div><strong>Eindruck:</strong> {lead.sympathie}</div>}
{(lead.portalLogin || lead.portalPasswort) && (
<div className="flex gap-3 wrap">
{lead.portalLogin && <CopyableCredential label="Portal-Login" value={lead.portalLogin} />}
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
</div>
)}
<PipelineLog pipeline={pipeline} />
{lead.pipelineStatus === 'failed' && lead.pipelineError && (
<div className="text-red" style={{ fontSize: 12 }}>
<FaTriangleExclamation /> {lead.pipelineError}
</div>
)}
<EmailDraft pipeline={pipeline} />
{lead.notiz && <div><strong>Notiz (Alt-Bestand):</strong> {lead.notiz}</div>}
<div className="faint" style={{ fontSize: 11 }}>
Aufgenommen: {lead.addedAt ? new Date(lead.addedAt).toLocaleDateString('de-DE') : '-'}
{lead.leadId ? ` · ${lead.leadId}` : ''}
</div>
</div>
)}
</div>
)
}
function LeadSettingsCard({ onClose }) {
const { settings, loading, saveSettings } = useLeadSettings()
const [form, setForm] = useState(null)
const [newStadt, setNewStadt] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
// Formular einmalig aus den geladenen Settings befuellen
if (!form && !loading && settings) {
setForm({
branche: settings.branche || '',
staedte: settings.staedte || [],
ganzDeutschland: Boolean(settings.ganzDeutschland),
maxLeadsProStadt: settings.maxLeadsProStadt ?? 5,
minBewertung: settings.minBewertung ?? 4.0,
emailPflicht: settings.emailPflicht !== false,
nurSchlechteWebsite: settings.nurSchlechteWebsite !== false,
zusatzHinweise: settings.zusatzHinweise || '',
})
}
const set = (key, value) => setForm((f) => ({ ...f, [key]: value }))
const addStadt = () => {
const s = newStadt.trim()
if (!s) return
if (!form.staedte.some((x) => x.toLowerCase() === s.toLowerCase())) {
set('staedte', [...form.staedte, s])
}
setNewStadt('')
}
const save = async () => {
setBusy(true)
setMsg(null)
const r = await saveSettings(form)
setBusy(false)
setMsg(r.success ? 'Gespeichert — gilt ab dem nächsten 06:00-Lauf.' : `Fehler: ${r.error}`)
}
return (
<Card
title="Sucheinstellungen der 06:00-Routine"
actions={<Button variant="ghost" size="sm" onClick={onClose}><FaXmark /></Button>}
>
{loading || !form ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : (
<>
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf.
Bereits vorhandene Leads und Kunden werden automatisch übersprungen.
</p>
<div className="flex gap-3 wrap">
<Field label="Branche" hint="z. B. Autolackiererei, Kfz-Werkstatt, Dachdecker">
<input
className="form-control"
value={form.branche}
onChange={(e) => set('branche', e.target.value)}
/>
</Field>
<Field label="Max. neue Leads pro Stadt">
<input
className="form-control"
type="number"
min={1}
max={20}
value={form.maxLeadsProStadt}
onChange={(e) => set('maxLeadsProStadt', e.target.value)}
style={{ width: 120 }}
/>
</Field>
<Field label="Min. Google-Bewertung">
<input
className="form-control"
type="number"
step={0.1}
min={0}
max={5}
value={form.minBewertung}
onChange={(e) => set('minBewertung', e.target.value)}
style={{ width: 120 }}
/>
</Field>
</div>
<Field
label="Städte / Regionen"
hint={form.ganzDeutschland ? 'Ganz Deutschland aktiv — die Routine wählt selbst passende Regionen.' : 'Die Routine recherchiert in diesen Regionen.'}
>
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
{form.staedte.map((s) => (
<Badge key={s} tone={form.ganzDeutschland ? 'muted' : 'info'}>
{s}
<button
type="button"
onClick={() => set('staedte', form.staedte.filter((x) => x !== s))}
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: '0 0 0 4px', display: 'inline-flex' }}
title={`${s} entfernen`}
>
<FaXmark />
</button>
</Badge>
))}
<input
className="form-control"
placeholder="Stadt hinzufügen..."
value={newStadt}
onChange={(e) => setNewStadt(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addStadt() } }}
disabled={form.ganzDeutschland}
style={{ width: 200 }}
/>
<Button size="sm" type="button" onClick={addStadt} disabled={form.ganzDeutschland}>
<FaPlus />
</Button>
</div>
</Field>
<div className="flex gap-3 wrap" style={{ margin: '10px 0' }}>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.ganzDeutschland}
onChange={(e) => set('ganzDeutschland', e.target.checked)}
/>
Komplett Deutschland
</label>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.emailPflicht}
onChange={(e) => set('emailPflicht', e.target.checked)}
/>
Nur Leads mit auffindbarer E-Mail
</label>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.nurSchlechteWebsite}
onChange={(e) => set('nurSchlechteWebsite', e.target.checked)}
/>
Nur ohne / mit veralteter Website
</label>
</div>
<Field label="Zusätzliche Hinweise für die Recherche" hint="Freitext, wird der Routine wörtlich mitgegeben.">
<textarea
className="form-control"
rows={3}
value={form.zusatzHinweise}
onChange={(e) => set('zusatzHinweise', e.target.value)}
placeholder="z. B. Familienbetriebe bevorzugen, keine Franchise-Ketten..."
/>
</Field>
{msg && <div className={msg.startsWith('Fehler') ? 'text-red' : 'muted'} style={{ marginBottom: 8, fontSize: 13 }}>{msg}</div>}
<div className="flex gap-2">
<Button variant="primary" onClick={save} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}
</Button>
</div>
</>
)}
</Card>
)
}
+105 -32
View File
@@ -1,45 +1,118 @@
import { useState } from 'react'
import { format, addDays, startOfWeek } from 'date-fns'
import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { format, addDays, startOfWeek, isSameDay } from 'date-fns'
import { de } from 'date-fns/locale'
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
import { useWorkorders } from '../hooks/useWorkorders'
import { useEmployees } from '../hooks/useEmployees'
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
die Aufbereitung steckt in src/lib/planboard.js. */
const TYPE_COLORS = {
akquise: { bg: 'rgba(198,113,57,0.16)', fg: '#c67139' },
webpage: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
project: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
}
export default function PlanboardPage() {
const navigate = useNavigate()
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const { workorders, loading } = useWorkorders({ limit: 500 })
const { employees } = useEmployees()
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
const goToday = () => setCurrentWeek(startOfWeek(new Date(), { weekStartsOn: 1 }))
const navigateWeek = (direction) => {
setCurrentWeek(prev => addDays(prev, direction * 7))
}
const byDay = useMemo(() => groupAppointmentsByDay(workorders || [], employees || []), [workorders, employees])
const weekCount = countInRange(byDay, weekDays)
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Planboard</h2>
</header>
<div className="planboard-nav text-center mb-2">
<button className="btn btn-dark" onClick={() => navigateWeek(-1)}>← Previous Week</button>
<span className="mx-2">
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span>
<button className="btn btn-dark" onClick={() => navigateWeek(1)}>Next Week →</button>
</div>
<div className="planboard-grid">
{weekDays.map(day => (
<div key={day.toISOString()} className="planboard-day">
<div className="day-header">
<strong>{format(day, 'EEEE')}</strong>
<span>{format(day, 'dd.MM')}</span>
</div>
<div className="day-content">
<p className="text-grey text-small">No tasks scheduled</p>
</div>
<div className="page">
<PageHeader
title="Planboard"
subtitle={loading ? 'Wochenplanung – lädt…' : `Wochenplanung – ${weekCount} ${weekCount === 1 ? 'Termin' : 'Termine'} diese Woche`}
actions={
<div className="flex gap-2 items-center">
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
<span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span>
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
<Button variant="ghost" onClick={goToday}>Heute</Button>
</div>
))}
</div>
}
/>
<div className="text-center mt-4 text-grey">
<p>Drag and drop tickets to schedule them on the planboard.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
{weekDays.map((day) => {
const entries = byDay[dayKey(day)] || []
const today = isSameDay(day, new Date())
return (
<Card key={day.toISOString()} pad={false}>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
background: today ? 'rgba(198,113,57,0.10)' : undefined,
}}>
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
</div>
<div style={{ padding: 8, minHeight: 120, display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.length === 0 && (
<p className="faint" style={{ fontSize: 12, padding: '4px 4px 0' }}>Keine Termine</p>
)}
{entries.map((entry) => {
const color = TYPE_COLORS[String(entry.type).toLowerCase()]
|| { bg: 'rgba(127,127,127,0.10)', fg: 'var(--border)' }
return (
<button
key={entry.id}
type="button"
title={`Ticket #${entry.woid} öffnen`}
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
style={{
textAlign: 'left',
border: '1px solid var(--border)',
borderLeft: `3px solid ${color.fg}`,
borderRadius: 8,
background: color.bg,
color: 'inherit',
padding: '8px 10px',
cursor: 'pointer',
font: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<span style={{ fontSize: 12, fontWeight: 700 }}>
{entry.time ? `${entry.time}${entry.endTime ? `–${entry.endTime}` : ''}` : 'ohne Uhrzeit'}
</span>
<span style={{ fontSize: 12.5, fontWeight: 600, lineHeight: 1.25 }}>
{entry.customerName || entry.topic}
</span>
{entry.customerName && (
<span className="faint" style={{ fontSize: 11.5, lineHeight: 1.25 }}>{entry.topic}</span>
)}
<span className="faint" style={{ fontSize: 11 }}>
#{entry.woid}
{entry.employeeName ? ` · ${entry.employeeShort || entry.employeeName}` : ' · niemand zugewiesen'}
</span>
</button>
)
})}
</div>
</Card>
)
})}
</div>
</div>
)
+737 -18
View File
@@ -1,39 +1,758 @@
import { useState } from 'react'
import { FaFolder, FaPlus } from 'react-icons/fa6'
import { useState, useEffect, useMemo } from 'react'
import {
FaFolder,
FaPlus,
FaArrowsRotate,
FaPen,
FaBoxArchive,
FaBoxOpen,
FaGlobe,
FaLock,
FaCircleInfo,
FaClone,
} from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders'
import { useEmployees } from '../hooks/useEmployees'
import { useAuth } from '../context/AuthContext'
import {
createProjectFromTemplate,
syncGiteaRepos,
updateWebsiteProject,
archiveWebsiteProject,
unarchiveWebsiteProject,
} from '../lib/projectAdminApi'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100)
}
const WORKORDER_FILTERS = { limit: 500 }
// Projekt-Typen (= "Template"/Hosting-Modus)
const TYPES = {
preview: {
label: 'Preview (mit Login)',
short: 'Preview',
badge: 'badge-info',
icon: <FaLock />,
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
},
website: {
label: 'Oeffentliche Website',
short: 'Website',
badge: 'badge-ok',
icon: <FaGlobe />,
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
},
template: {
label: 'Vorlage',
short: 'Vorlage',
badge: 'badge-warn',
icon: <FaClone />,
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
adminOnly: true,
},
project: {
label: 'Internes Projekt (kein Hosting)',
short: 'Projekt',
badge: 'badge-muted',
icon: <FaFolder />,
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
},
}
function typeOf(project) {
if (project.isTemplate) return 'template'
if (project.isPublic) return 'website'
if (project.subdomain) return 'preview'
return 'project'
}
// Typen mit Hosting (Subdomain + Deploy)
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
function isProjectReady(project) {
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
return ['ready', 'deployed'].includes(st)
}
export default function ProjectsPage() {
const [projects] = useState([])
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
const { employees } = useEmployees()
const { isAdmin } = useAuth()
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [customerFilter, setCustomerFilter] = useState('')
const [employeeFilter, setEmployeeFilter] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
displayName: '',
subdomain: '',
repoName: '',
customerId: '',
})
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState('')
const [syncLoading, setSyncLoading] = useState(false)
const [notice, setNotice] = useState('')
const [assignBusyId, setAssignBusyId] = useState('')
const [hostBusyId, setHostBusyId] = useState('')
const [refreshBusyId, setRefreshBusyId] = useState('')
// Bearbeiten-Modal
const [editProject, setEditProject] = useState(null)
const [editForm, setEditForm] = useState({ projectName: '', subdomain: '', projectType: 'preview' })
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
const [archiveBusy, setArchiveBusy] = useState(false)
useEffect(() => {
fetchAllProjects()
}, [fetchAllProjects])
const customerMap = useMemo(() => {
const map = {}
for (const c of customers) {
map[c.$id] = c.name || c.code || c.$id
}
return map
}, [customers])
const ticketMap = useMemo(() => {
const map = {}
for (const wo of workorders) {
map[wo.$id] = wo.woid || wo.$id
}
return map
}, [workorders])
// Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo)
const workorderById = useMemo(() => {
const map = {}
for (const wo of workorders) map[wo.$id] = wo
return map
}, [workorders])
const sortedCustomers = useMemo(
() => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))),
[customers]
)
const sortedEmployees = useMemo(
() => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))),
[employees]
)
const counts = useMemo(() => {
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
const active = projects.filter((p) => !p.archived && !p.isTemplate)
return {
all: active.length,
unassigned: active.filter((p) => !p.customerId).length,
assigned: active.filter((p) => Boolean(p.customerId)).length,
archived: projects.filter((p) => p.archived).length,
templates: projects.filter((p) => p.isTemplate && !p.archived).length,
}
}, [projects])
const filteredProjects = useMemo(() => {
// 1) Kategorie (Dropdown)
let base
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
else if (filter === 'archived') base = projects.filter((p) => p.archived)
else {
const active = projects.filter((p) => !p.archived && !p.isTemplate)
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
else base = active
}
// 2) Textsuche (Titel/Subdomain/Repo)
const q = search.trim().toLowerCase()
if (q) {
base = base.filter((p) =>
[p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) =>
String(v || '').toLowerCase().includes(q)
)
)
}
// 3) Kunde
if (customerFilter) base = base.filter((p) => p.customerId === customerFilter)
// 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo)
if (employeeFilter) {
base = base.filter((p) => {
const wo = p.ticketId ? workorderById[p.ticketId] : null
return wo && wo.assignedTo === employeeFilter
})
}
return base
}, [projects, filter, search, customerFilter, employeeFilter, workorderById])
const handleCreate = async (e) => {
e.preventDefault()
setCreateLoading(true)
setCreateError('')
try {
const result = await createProjectFromTemplate({
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
displayName: createForm.displayName,
subdomain: slugify(createForm.subdomain),
customerId: createForm.customerId || '',
ticketId: '',
})
setShowCreateModal(false)
setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' })
setNotice(
result.previewUrl
? `Projekt angelegt - Website: ${result.previewUrl}`
: 'Projekt (ohne Website) angelegt.'
)
await fetchAllProjects()
} catch (err) {
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
} finally {
setCreateLoading(false)
}
}
const handleSyncRepos = async () => {
setSyncLoading(true)
setNotice('')
try {
const r = await syncGiteaRepos()
setNotice(`Gitea-Sync: ${r.synced ?? '?'} Repos (${r.created ?? 0} neu, ${r.updated ?? 0} aktualisiert)`)
await fetchAllProjects()
} catch (err) {
setNotice('Gitea-Sync fehlgeschlagen: ' + (err.message || 'unbekannt'))
} finally {
setSyncLoading(false)
}
}
const handleAssignCustomer = async (project, customerId) => {
setAssignBusyId(project.$id)
setNotice('')
const r = customerId
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
: await unassignProject(project.$id)
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
await fetchAllProjects()
setAssignBusyId('')
}
const handleHostNow = async (project) => {
setHostBusyId(project.$id)
setNotice('')
try {
// Deploy ueber die vorhandene PATCH-Route anstossen (Typ bleibt gleich)
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
setNotice(
r.deploy?.deployed
? `"${project.projectName}" wird jetzt gehostet${r.previewUrl ? ': ' + r.previewUrl : ''}`
: `Deploy angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
)
await fetchAllProjects()
} catch (err) {
setNotice('Hosten fehlgeschlagen: ' + (err.message || 'unbekannt'))
} finally {
setHostBusyId('')
}
}
// Bereits gehostetes Projekt erneut deployen (nach Website-Ueberarbeitung neu laden)
const handleRefresh = async (project) => {
setRefreshBusyId(project.$id)
setNotice('')
try {
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
setNotice(
r.deploy?.deployed
? `"${project.projectName}" wurde aktualisiert${r.previewUrl ? ': ' + r.previewUrl : ''}`
: `Aktualisierung angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
)
await fetchAllProjects()
} catch (err) {
setNotice('Aktualisieren fehlgeschlagen: ' + (err.message || 'unbekannt'))
} finally {
setRefreshBusyId('')
}
}
const openEdit = (project) => {
setEditError('')
setEditProject(project)
setEditForm({
projectName: project.projectName || '',
subdomain: project.subdomain || '',
projectType: typeOf(project),
})
}
const handleEditSave = async (e) => {
e.preventDefault()
if (!editProject) return
setEditLoading(true)
setEditError('')
try {
const payload = {
projectName: editForm.projectName,
projectType: editForm.projectType,
}
if (editForm.projectType !== 'project') {
payload.subdomain = slugify(editForm.subdomain)
}
const result = await updateWebsiteProject(editProject.$id, payload)
setEditProject(null)
setNotice(
result.previewUrl
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
: 'Projekt aktualisiert.'
)
await fetchAllProjects()
} catch (err) {
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
} finally {
setEditLoading(false)
}
}
const handleArchiveToggle = async (project) => {
const isArchived = Boolean(project.archived)
if (!isArchived && !window.confirm(`Repository "${project.projectName}" wirklich archivieren? Die Preview-Website wird entfernt.`)) {
return
}
setArchiveBusy(true)
setEditError('')
try {
if (isArchived) {
await unarchiveWebsiteProject(project.$id)
setNotice(`"${project.projectName}" reaktiviert.`)
} else {
await archiveWebsiteProject(project.$id)
setNotice(`"${project.projectName}" archiviert.`)
}
setEditProject(null)
await fetchAllProjects()
} catch (err) {
setEditError(err.message || 'Archivieren fehlgeschlagen')
} finally {
setArchiveBusy(false)
}
}
const FILTER_OPTIONS = [
{ value: 'all', label: `Alle Projekte (${counts.all})` },
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
// Vorlagen-Ansicht nur fuer Admins
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
{ value: 'archived', label: `Archiviert (${counts.archived})` },
]
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Projects</h2>
<h2>Website-Projekte</h2>
</header>
<div className="text-center mb-2">
<button className="btn btn-teal">
<FaPlus /> New Project
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="search"
className="form-control"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
/>
<select
className="form-control"
style={{ width: 'auto', minWidth: '170px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
{FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => setShowCreateModal(true)}
title="Neues Projekt"
aria-label="Neues Projekt"
>
<FaPlus />
</button>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={handleSyncRepos}
disabled={syncLoading}
title="Gitea-Repos synchronisieren"
aria-label="Gitea-Repos synchronisieren"
>
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
</div>
{projects.length === 0 ? (
{notice && (
<div className="text-center mb-2">
<span className="text-grey">{notice}</span>
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
{loading ? (
<p className="text-center text-grey">Projekte werden geladen...</p>
) : filteredProjects.length === 0 ? (
<div className="text-center p-4">
<FaFolder size={64} className="text-grey" />
<p className="text-grey mt-2">No projects yet. Create your first project to get started.</p>
<p className="text-grey mt-2">
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
</p>
</div>
) : (
<div className="projects-grid">
{projects.map(project => (
<div key={project.$id} className="project-card">
<h4>{project.name}</h4>
<p className="text-grey">{project.description}</p>
<div className="project-meta">
<span>{project.ticketCount || 0} tickets</span>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
gap: '16px',
alignItems: 'start',
}}
>
{filteredProjects.map((project) => {
const type = typeOf(project)
const t = TYPES[type]
const hosted = HOSTED_TYPES.has(type)
const ready = isProjectReady(project)
const needsHosting = hosted && !ready && !project.archived
return (
<div
key={project.$id}
className="card"
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
{project.projectName || project.giteaRepoName || '(ohne Name)'}
</div>
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
{project.repoFullName}
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
{t.icon} {t.short}
</span>
{hosted && !project.archived && (
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
{ready ? 'gehostet' : 'nicht deployt'}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
<Row label="Subdomain">
{project.subdomain
? <code>{project.subdomain}</code>
: <span className="text-grey">ohne Website</span>}
</Row>
<Row label="Ticket">
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
</Row>
<Row label="Status">
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
</Row>
</div>
<div>
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
<select
className="form-control"
style={{ fontSize: '13px' }}
value={project.customerId || ''}
disabled={assignBusyId === project.$id || project.archived}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => openEdit(project)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
<FaPen />
</button>
{needsHosting ? (
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => handleHostNow(project)}
disabled={hostBusyId === project.$id}
title="Projekt jetzt deployen/hosten"
aria-label="Projekt jetzt deployen/hosten"
>
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
) : (
<PreviewLinkButton href={project.previewUrl} />
)}
{hosted && ready && !project.archived && (
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => handleRefresh(project)}
disabled={refreshBusyId === project.$id}
title="Aktualisieren – Website neu deployen"
aria-label="Aktualisieren – Website neu deployen"
>
<FaArrowsRotate
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
/>
</button>
)}
<GiteaLinkButton href={project.giteaRepoUrl} />
</div>
</div>
</div>
))}
)
})}
</div>
)}
{/* --- Neues Projekt --- */}
{showCreateModal && (
<div className="overlay">
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Website-Projekt</h2>
{createError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{createError}
</div>
)}
<form onSubmit={handleCreate}>
<div className="form-group">
<label className="form-label">Anzeigename</label>
<input
type="text"
className="form-control"
value={createForm.displayName}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Subdomain (optional)</label>
<input
type="text"
className="form-control"
value={createForm.subdomain}
onChange={(e) =>
setCreateForm((p) => ({
...p,
subdomain: e.target.value,
repoName: p.repoName || slugify(e.target.value),
}))
}
placeholder="leer lassen = Projekt ohne Website"
/>
<small className="text-grey">
Mit Subdomain wird die Website automatisch unter https://&lt;subdomain&gt;.project.webklar.com deployt.
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
</small>
</div>
<div className="form-group">
<label className="form-label">Repo-Name (optional)</label>
<input
type="text"
className="form-control"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
/>
</div>
<div className="form-group">
<label className="form-label">Kunde (optional)</label>
<select
className="form-control"
value={createForm.customerId}
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</div>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={createLoading}>
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
</button>
</div>
</form>
</div>
</div>
)}
{/* --- Projekt bearbeiten --- */}
{editProject && (
<div className="overlay">
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Projekt bearbeiten</h2>
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
{editProject.repoFullName}
</p>
{editError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{editError}
</div>
)}
<form onSubmit={handleEditSave}>
<div className="form-group">
<label className="form-label">Name</label>
<input
type="text"
className="form-control"
value={editForm.projectName}
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Typ / Template</label>
<select
className="form-control"
value={editForm.projectType}
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
>
{typeOptions.map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
<small className="text-grey">
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
</small>
</div>
{editForm.projectType !== 'project' && (
<div className="form-group">
<label className="form-label">Subdomain</label>
<input
type="text"
className="form-control"
value={editForm.subdomain}
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="z.B. musterfirma"
/>
<small className="text-grey">
Ergebnis: https://{slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com
{' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
</small>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ background: editProject.archived ? 'var(--accent)' : 'var(--danger)', color: '#fff' }}
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editProject.archived
? <><FaBoxOpen /> Reaktivieren</>
: <><FaBoxArchive /> Repository archivieren</>}
</button>
<button type="submit" className="btn btn-dark" disabled={editLoading || archiveBusy}>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
function Row({ label, children }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
<span className="text-grey">{label}</span>
<span style={{ textAlign: 'right', wordBreak: 'break-word' }}>{children}</span>
</div>
)
}
+24 -57
View File
@@ -1,81 +1,48 @@
import { useState } from 'react'
import { format, subDays } from 'date-fns'
import { FaFileExport, FaChartBar } from 'react-icons/fa6'
import { PageHeader, Card, Button, Field, EmptyState } from '../components/ui'
export default function ReportsPage() {
const [dateRange, setDateRange] = useState({
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
to: format(new Date(), 'yyyy-MM-dd')
to: format(new Date(), 'yyyy-MM-dd'),
})
const [reportType, setReportType] = useState('tickets')
const reportTypes = [
{ value: 'tickets', label: 'Ticket Summary' },
{ value: 'performance', label: 'Performance Report' },
{ value: 'customer', label: 'Customer Report' },
{ value: 'technician', label: 'Technician Report' }
{ value: 'tickets', label: 'Ticket-Uebersicht' },
{ value: 'performance', label: 'Leistungsbericht' },
{ value: 'customer', label: 'Kundenbericht' },
{ value: 'technician', label: 'Techniker-Bericht' },
]
const handleGenerateReport = () => {
// Would generate report from Appwrite data
alert(`Generating ${reportType} report from ${dateRange.from} to ${dateRange.to}`)
const handleGenerate = () => {
alert(`Bericht "${reportType}" von ${dateRange.from} bis ${dateRange.to}`)
}
return (
<div className="main-content">
<header className="text-center mb-2">
<h2>Reports</h2>
</header>
<div className="page">
<PageHeader title="Reports" subtitle="Auswertungen und Exporte" />
<div className="report-filters card p-2">
<div className="filter-row">
<label>
Report Type:
<select
value={reportType}
onChange={(e) => setReportType(e.target.value)}
className="input ml-1"
>
{reportTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
<Card title="Bericht erstellen">
<div className="flex gap-3 wrap">
<Field label="Berichtstyp">
<select className="form-control" value={reportType} onChange={(e) => setReportType(e.target.value)}>
{reportTypes.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
</Field>
<Field label="Von"><input className="form-control" type="date" value={dateRange.from} onChange={(e) => setDateRange((p) => ({ ...p, from: e.target.value }))} /></Field>
<Field label="Bis"><input className="form-control" type="date" value={dateRange.to} onChange={(e) => setDateRange((p) => ({ ...p, to: e.target.value }))} /></Field>
</div>
<div className="filter-row mt-2">
<label>
From:
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange(prev => ({ ...prev, from: e.target.value }))}
className="input ml-1"
/>
</label>
<label className="ml-2">
To:
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange(prev => ({ ...prev, to: e.target.value }))}
className="input ml-1"
/>
</label>
<div className="flex gap-2" style={{ marginTop: 8 }}>
<Button variant="primary" onClick={handleGenerate}><FaChartBar /> Bericht erstellen</Button>
<Button variant="ghost"><FaFileExport /> Als PDF exportieren</Button>
</div>
</Card>
<div className="text-center mt-2">
<button className="btn btn-teal" onClick={handleGenerateReport}>
<FaChartBar /> Generate Report
</button>
<button className="btn btn-dark ml-1">
<FaFileExport /> Export PDF
</button>
</div>
</div>
<div className="text-center mt-4 text-grey">
<p>Select report parameters and click Generate to view your report.</p>
<div style={{ marginTop: 16 }}>
<Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
</div>
</div>
)
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more