Compare commits

41 Commits
Author SHA1 Message Date
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
98 changed files with 9979 additions and 2123 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 || ''
}
+9 -28
View File
@@ -5,8 +5,8 @@ import { useEmployees } from '../hooks/useEmployees'
// Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
'Akquise', 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
]
@@ -55,8 +55,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
topic: '',
requestedBy: '',
requestedFor: '',
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
startDate: today,
startTime: '',
deadline: today,
@@ -235,30 +233,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>
+17 -3
View File
@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
'n/a'
]
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', {
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
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')
// Reset form wenn Modal geöffnet wird
useEffect(() => {
if (isOpen && workorder) {
setFormData({
serviceType: 'Remote',
newStatus: workorder.status || 'Open',
newStatus: defaultStatus,
newResponseLevel: workorder.responseLevel || '',
totalTime: 0,
startDate: today,
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</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>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
+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>
)
}
@@ -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>
)
}
+172
View File
@@ -0,0 +1,172 @@
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 ResponseDropdown from './ResponseDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList'
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 { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
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 [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
const { worksheets, loading: worksheetsLoading, createWorksheet, getTotalTime } = useWorksheets(
expanded ? ticket.woid : null
)
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de, 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,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel,
})
}
// 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.systemType && ticket.systemType !== 'n/a' && <Badge tone="muted">{ticket.systemType}</Badge>}
{ticket.serviceType && <Badge tone="info">{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 style={{ minWidth: 150 }}><ResponseDropdown value={ticket.responseLevel} onChange={(v) => onUpdate(ticket.$id, { responseLevel: 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} />
</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} />
<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>
)
}
+127 -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,42 @@ 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)
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 +89,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 +134,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 +157,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 +237,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} />
</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>
)
}
+50 -10
View File
@@ -1,7 +1,11 @@
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
export default function WorksheetList({ worksheets, totalTime, loading }) {
function needsTimeEntry(ws) {
return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime))
}
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
@@ -49,13 +53,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 +75,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 +87,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 +109,20 @@ 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>
)}
</div>
{/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && (
@@ -126,9 +144,31 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
</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={{
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',
}}
onClick={(e) => {
e.stopPropagation()
onEditTime(ws)
}}
>
<FaClock className="me-1" /> Zeit nachtragen
</button>
)}
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
</small>
</div>
</div>
{/* Body - Nur wenn expanded */}
+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>
)
}
+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>
)
}
+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>
)
}
+9
View File
@@ -0,0 +1,9 @@
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'
+107 -44
View File
@@ -1,24 +1,86 @@
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
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(
DATABASE_ID,
@@ -28,13 +90,12 @@ 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')
}
} 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)
}
@@ -51,7 +112,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 +119,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)
} 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 +145,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)
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 +194,25 @@ 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)
}
}
// 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,
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
}
}
+70 -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,17 @@ 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,
[Query.orderAsc('name')]
)
setCustomers(response.documents || [])
}
setError(null)
} catch (err) {
console.error('Error fetching customers:', err)
@@ -50,20 +67,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 +93,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 +140,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 +158,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 }
}
+114 -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,8 @@ export function useWorkorders(filters = {}) {
responseLevel: data.responseLevel || '',
serviceType: data.serviceType || 'Remote',
customerId: data.customerId || '',
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
assignedTo: automatic.assignedTo,
assignedName: automatic.assignedName,
requestedBy: data.requestedBy || '',
requestedFor: data.requestedFor || '',
startDate: data.startDate || '',
+2 -2
View File
@@ -1,7 +1,7 @@
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'
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)
+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 }
}
+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 */
}
}
}
+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; }
}
+492 -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} � {t.systemType || 'n/a'}</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>
)
}
+29 -32
View File
@@ -1,45 +1,42 @@
import { useState } from 'react'
import { format, addDays, startOfWeek } from 'date-fns'
import { de } from 'date-fns/locale'
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
export default function PlanboardPage() {
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
const navigateWeek = (direction) => {
setCurrentWeek(prev => addDays(prev, direction * 7))
}
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
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="Wochenplanung"
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>
</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) => (
<Card key={day.toISOString()} pad={false}>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<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: 12, minHeight: 120 }}>
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
</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>
)
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { IconArrowLeft, IconColumnInsertRight, IconTicket, IconAlertTriangle } from '@tabler/icons-react'
import { PageHeader, Card, Button, Badge } from '../components/ui'
import RoadmapBoard from '../components/roadmap/RoadmapBoard'
import NodeDetailPanel from '../components/roadmap/NodeDetailPanel'
import RoadmapProgressBar from '../components/roadmap/RoadmapProgressBar'
import { useAuth } from '../context/AuthContext'
import { useRoadmaps } from '../hooks/useRoadmaps'
import { useRoadmapNodes } from '../hooks/useRoadmapNodes'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { computeProgress, NODE_KIND, ROADMAP_STATUS } from '../lib/roadmaps'
export default function RoadmapDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const { fetchById, updateRoadmap, completePlan } = useRoadmaps()
const {
nodes, fetchNodes, createNode, updateNode, deleteNode,
connectNodes, disconnectNodes, setNodeStatus,
} = useRoadmapNodes()
const [roadmap, setRoadmap] = useState(null)
const [ticketStatus, setTicketStatus] = useState(null)
const [selectedNode, setSelectedNode] = useState(null)
const [notFound, setNotFound] = useState(false)
const load = useCallback(async () => {
const doc = await fetchById(id)
if (!doc) {
setNotFound(true)
return
}
setRoadmap(doc)
await fetchNodes(doc.$id)
if (doc.ticketId) {
try {
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, doc.ticketId)
setTicketStatus(ticket.status)
} catch {
setTicketStatus(null)
}
}
}, [id, fetchById, fetchNodes])
useEffect(() => { load() }, [load])
// Teamarbeit: bei Fenster-Fokus neu laden (einfacher Schutz gegen veraltete Ansicht)
useEffect(() => {
const onFocus = () => load()
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [load])
// Ausgewaehlten Knoten mit frischen Daten aus der Liste spiegeln
useEffect(() => {
if (!selectedNode) return
const fresh = nodes.find((n) => n.$id === selectedNode.$id)
if (!fresh) setSelectedNode(null)
else if (fresh !== selectedNode) setSelectedNode(fresh)
}, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
if (notFound) {
return (
<div className="page">
<PageHeader title="Roadmap nicht gefunden" />
<Button onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zur Uebersicht</Button>
</div>
)
}
if (!roadmap) {
return <div className="page"><p className="muted">Lade Roadmap…</p></div>
}
const readOnly = roadmap.status === ROADMAP_STATUS.COMPLETED
const progress = computeProgress(nodes)
const ticketClosedButActive = !readOnly && ticketStatus === 'Closed'
const handleAddNode = async (column, kind, rowCount) => {
const result = await createNode(roadmap.$id, {
title: kind === NODE_KIND.GOAL ? 'Neues Ziel' : 'Neuer Schritt',
column,
row: rowCount,
kind,
})
if (result.success) setSelectedNode(result.data)
else window.alert(result.error)
}
const handleConnect = async (source, target) => {
const result = await connectNodes(source, target)
if (!result.success) window.alert(result.error)
}
const handleSaveNode = async (node, data) => updateNode(node.$id, data)
const handleSetStatus = async (node, status) => {
const result = await setNodeStatus(roadmap, node, status, user)
if (!result.success) {
window.alert(result.error)
return
}
if (result.allGoalsDone) {
const ok = window.confirm(
'Alle Ziel-Knoten sind fertig!\n\n' +
'Plan abschliessen und archivieren? Das Projektticket wird dabei geschlossen.'
)
if (ok) {
const done = await completePlan(roadmap, user)
if (done.success) setRoadmap(done.data)
else window.alert(`Abschliessen fehlgeschlagen: ${done.error}`)
}
}
}
const handleDeleteNode = async (node) => {
if (!window.confirm(`Knoten "${node.title}" loeschen?`)) return
const result = await deleteNode(node)
if (!result.success) window.alert(result.error)
else setSelectedNode(null)
}
const handleDisconnect = async (sourceId, target) => {
const result = await disconnectNodes(sourceId, target)
if (!result.success) window.alert(result.error)
}
/** Neue Phase vor den Zielen einfuegen: Ziel-Knoten ruecken eine Spalte nach rechts. */
const handleAddColumn = async () => {
const newCount = (roadmap.columnCount || 3) + 1
if (newCount > 50) return
const goals = nodes.filter((n) => n.kind === NODE_KIND.GOAL)
for (const goal of goals) {
await updateNode(goal.$id, { column: newCount - 1 })
}
const result = await updateRoadmap(roadmap.$id, { columnCount: newCount })
if (result.success) setRoadmap(result.data)
else window.alert(result.error)
}
return (
<div className="page">
<PageHeader
title={roadmap.title}
subtitle={`Projekt: ${roadmap.projectName || '-'}${roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}`}
actions={
<div className="flex gap-2 items-center" style={{ flexWrap: 'wrap' }}>
<RoadmapProgressBar progress={progress} compact />
{readOnly && <Badge tone="ok">Abgeschlossen</Badge>}
{roadmap.woid && (
<Link to={`/tickets?woid=${roadmap.woid}`}>
<Button variant="ghost"><IconTicket size={16} /> Ticket {roadmap.woid}</Button>
</Link>
)}
{!readOnly && (
<Button variant="ghost" onClick={handleAddColumn} title="Neue Phase vor den Zielen einfuegen">
<IconColumnInsertRight size={16} /> Spalte
</Button>
)}
<Button variant="ghost" onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zurueck</Button>
</div>
}
/>
{ticketClosedButActive && (
<Card style={{ marginBottom: 16, borderColor: 'rgba(245,158,11,0.5)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13 }}>
<IconAlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<span>
Das Projektticket wurde manuell geschlossen, der Plan ist aber erst zu {progress.percent}% fertig.
Statusaenderungen werden weiterhin im Ticketverlauf dokumentiert.
</span>
</div>
</Card>
)}
<div style={{ display: 'grid', gridTemplateColumns: selectedNode ? 'minmax(0, 1fr) 320px' : '1fr', gap: 20, alignItems: 'start' }}>
<Card pad={false} bodyStyle={{ padding: 12 }} style={{ padding: 12 }}>
<RoadmapBoard
roadmap={roadmap}
nodes={nodes}
selectedNode={selectedNode}
readOnly={readOnly}
onSelectNode={setSelectedNode}
onAddNode={handleAddNode}
onConnect={handleConnect}
/>
</Card>
{selectedNode && (
<NodeDetailPanel
node={selectedNode}
nodes={nodes}
readOnly={readOnly}
onClose={() => setSelectedNode(null)}
onSave={handleSaveNode}
onSetStatus={handleSetStatus}
onDelete={handleDeleteNode}
onDisconnect={handleDisconnect}
/>
)}
</div>
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FaTimes } from 'react-icons/fa'
import { IconRoute, IconPlus, IconTrash, IconTicket } from '@tabler/icons-react'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
import { useAuth } from '../context/AuthContext'
import { useRoadmaps } from '../hooks/useRoadmaps'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { fetchRoadmapNodes, computeProgress, ROADMAP_STATUS } from '../lib/roadmaps'
import RoadmapProgress from '../components/roadmap/RoadmapProgressBar'
export default function RoadmapsPage() {
const { user } = useAuth()
const navigate = useNavigate()
const { roadmaps, loading, fetchAll, createRoadmap, deleteRoadmap } = useRoadmaps()
const { fetchAllProjects } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [progressById, setProgressById] = useState({})
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ projectId: '', title: '', goalTitle: '' })
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
const [allProjects, allRoadmaps] = await Promise.all([fetchAllProjects(), fetchAll()])
if (cancelled) return
setProjects(allProjects.filter((p) => !p.isTemplate && !p.archived))
const active = allRoadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
const entries = await Promise.all(active.map(async (r) => {
try {
return [r.$id, computeProgress(await fetchRoadmapNodes(r.$id))]
} catch {
return [r.$id, null]
}
}))
if (!cancelled) setProgressById(Object.fromEntries(entries.filter(([, p]) => p)))
}
load()
return () => { cancelled = true }
}, [fetchAll, fetchAllProjects])
const activeRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
const completedRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.COMPLETED)
// Nur Projekte ohne aktiven Plan anbieten (genau 1 aktiver Plan pro Projekt)
const availableProjects = useMemo(() => {
const withActivePlan = new Set(activeRoadmaps.map((r) => r.projectId))
return projects.filter((p) => !withActivePlan.has(p.$id))
}, [projects, activeRoadmaps])
const openCreate = () => {
setForm({ projectId: availableProjects[0]?.$id || '', title: '', goalTitle: '' })
setError('')
setShowCreate(true)
}
const handleCreate = async (e) => {
e.preventDefault()
const project = projects.find((p) => p.$id === form.projectId)
if (!project) {
setError('Bitte ein Projekt auswaehlen.')
return
}
setCreating(true)
setError('')
const result = await createRoadmap(project, { title: form.title.trim(), goalTitle: form.goalTitle.trim() }, user)
setCreating(false)
if (!result.success) {
setError(result.error)
return
}
setShowCreate(false)
navigate(`/roadmaps/${result.data.roadmap.$id}`)
}
const handleDelete = async (roadmap) => {
const ok = window.confirm(
`Plan "${roadmap.title}" wirklich loeschen?\n\n` +
`Alle Knoten werden entfernt und das Projektticket (WOID ${roadmap.woid || '-'}) wird abgebrochen.`
)
if (!ok) return
const result = await deleteRoadmap(roadmap, user)
if (!result.success) window.alert(`Loeschen fehlgeschlagen: ${result.error}`)
}
const renderRoadmapCard = (roadmap) => {
const progress = progressById[roadmap.$id]
return (
<Card key={roadmap.$id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<Link to={`/roadmaps/${roadmap.$id}`} style={{ fontWeight: 700, fontSize: 15 }}>
{roadmap.title}
</Link>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
Projekt: {roadmap.projectName || '-'}
{roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}
</div>
{roadmap.woid && (
<div className="faint" style={{ fontSize: 12, marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
<IconTicket size={14} />
<Link to={`/tickets?woid=${roadmap.woid}`}>Projektticket WOID {roadmap.woid}</Link>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{roadmap.status === ROADMAP_STATUS.COMPLETED
? <Badge tone="ok">Abgeschlossen</Badge>
: progress && <RoadmapProgress progress={progress} />}
<div style={{ display: 'flex', gap: 6 }}>
<Button variant="ghost" onClick={() => navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen</Button>
{roadmap.status === ROADMAP_STATUS.ACTIVE && (
<Button variant="ghost" title="Plan loeschen" onClick={() => handleDelete(roadmap)}>
<IconTrash size={16} />
</Button>
)}
</div>
</div>
</div>
</Card>
)
}
return (
<div className="page">
<PageHeader
title="Roadmaps"
subtitle="Projektspezifische Plaene — jeder Plan trackt sich automatisch in einem Projektticket"
actions={
<Button variant="primary" onClick={openCreate} disabled={!availableProjects.length && !loading}>
<IconPlus size={16} /> Plan erstellen
</Button>
}
/>
{loading && !roadmaps.length ? (
<p className="muted">Lade Roadmaps…</p>
) : (
<>
{!activeRoadmaps.length ? (
<EmptyState
icon={<IconRoute size={40} />}
title="Noch keine aktiven Plaene"
hint="Erstelle zu einem Projekt einen Plan — das zugehoerige Projektticket wird automatisch angelegt."
action={availableProjects.length
? <Button variant="primary" onClick={openCreate}><IconPlus size={16} /> Plan erstellen</Button>
: null}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{activeRoadmaps.map(renderRoadmapCard)}
</div>
)}
{completedRoadmaps.length > 0 && (
<>
<h3 style={{ margin: '28px 0 12px' }}>Archiv</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{completedRoadmaps.map(renderRoadmapCard)}
</div>
</>
)}
</>
)}
{showCreate && (
<div className="overlay">
<span className="overlay-close" onClick={() => setShowCreate(false)}><FaTimes /></span>
<div className="overlay-content" style={{ maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Neuen Plan erstellen</h2>
<p className="muted" style={{ fontSize: 13 }}>
Zum Plan wird automatisch ein Projektticket erstellt, in dem der Fortschritt getrackt wird.
</p>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Field label="Projekt *">
<select
value={form.projectId}
onChange={(e) => setForm((f) => ({ ...f, projectId: e.target.value }))}
required
>
<option value="" disabled>Projekt auswaehlen…</option>
{availableProjects.map((p) => (
<option key={p.$id} value={p.$id}>{p.projectName}</option>
))}
</select>
</Field>
<Field label="Titel des Plans" hint="Leer lassen fuer 'Plan: <Projektname>'">
<input
type="text"
value={form.title}
maxLength={128}
placeholder="z.B. Website-Relaunch"
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
/>
</Field>
<Field label="Erstes Ziel" hint="Der Zielknoten ganz rechts — weitere Ziele kannst du im Board hinzufuegen">
<input
type="text"
value={form.goalTitle}
maxLength={128}
placeholder="z.B. Website live"
onChange={(e) => setForm((f) => ({ ...f, goalTitle: e.target.value }))}
/>
</Field>
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button type="button" variant="ghost" onClick={() => setShowCreate(false)}>Abbrechen</Button>
<Button type="submit" variant="primary" disabled={creating}>
{creating ? 'Erstelle…' : 'Plan + Ticket erstellen'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+128 -381
View File
@@ -1,43 +1,58 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
import { useWorkorders } from '../hooks/useWorkorders'
import { useCustomers } from '../hooks/useCustomers'
import TicketRow from '../components/TicketRow'
import TicketCard from '../components/TicketCard'
import CreateTicketModal from '../components/CreateTicketModal'
import QuickOverviewModal from '../components/QuickOverviewModal'
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
// Alle Ticket-Status; "Closed" gilt als archiviert und ist standardmaessig ausgeblendet
const ALL_STATUSES = [
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed',
]
const ARCHIVED_STATUSES = ['Closed']
const DEFAULT_STATUSES = ALL_STATUSES.filter((s) => !ARCHIVED_STATUSES.includes(s))
export default function TicketsPage() {
const [limit, setLimit] = useState(10)
// Aktive Filter (werden für API-Calls verwendet)
const [filters, setFilters] = useState({
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
type: [],
priority: [],
limit: 10
})
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
const [localFilters, setLocalFilters] = useState({
woid: '',
customer: '',
userTopic: '',
createdDate: '',
type: '',
system: '',
priority: ''
woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
})
const toggleStatus = (status) => {
setStatusSel((prev) =>
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
)
}
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showOverviewModal, setShowOverviewModal] = useState(false)
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [searchParams] = useSearchParams()
const handleApplyFilters = () => {
// Wende lokale Filter auf aktive Filter an
setFilters(prev => ({
// Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
// (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
useEffect(() => {
const w = searchParams.get('woid')
if (!w) return
setLocalFilters((p) => ({ ...p, woid: w }))
setShowAdvanced(true)
setStatusSel(ALL_STATUSES)
setFilters((prev) => ({ ...prev, status: ALL_STATUSES, woid: w }))
}, [searchParams])
const applyFilters = () => {
setFilters((prev) => ({
...prev,
status: statusSel.length ? statusSel : ALL_STATUSES,
woid: localFilters.woid || undefined,
customer: localFilters.customer || undefined,
userTopic: localFilters.userTopic || undefined,
@@ -45,380 +60,112 @@ export default function TicketsPage() {
type: localFilters.type ? [localFilters.type] : [],
system: localFilters.system ? [localFilters.system] : [],
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
limit: limit
limit,
}))
}
const handleLimitChange = (e) => {
const newLimit = parseInt(e.target.value)
setLimit(newLimit)
// Limit-Änderung wird sofort angewendet (kein Apply nötig)
setFilters(prev => ({ ...prev, limit: newLimit }))
}
const handleUpdate = async (id, data) => {
await updateWorkorder(id, data)
}
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
const handleCreate = async (data) => {
const result = await createWorkorder(data)
if (result.success) {
setShowCreateModal(false)
}
if (result.success) setShowCreateModal(false)
return result
}
const handleLoadMore = () => {
setLimit(prev => prev + 10)
setFilters(prev => ({ ...prev, limit: prev.limit + 10 }))
const loadMore = () => {
const next = limit + 10
setLimit(next)
setFilters((prev) => ({ ...prev, limit: next }))
}
return (
<div className="main-content">
{/* Sticky Header Container */}
<div style={{
position: 'sticky',
top: 0,
zIndex: 100,
marginBottom: '24px'
}}>
{/* Compact Control Panel */}
<div style={{
background: 'rgba(26, 32, 44, 0.4)',
backdropFilter: 'blur(25px) saturate(180%)',
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.3)',
overflow: 'hidden',
padding: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
marginBottom: '16px'
}}>
{/* Title Row */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2 style={{
color: 'var(--dark-text)',
fontSize: '24px',
fontWeight: 'bold',
margin: 0
}}>
Tickets
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-dark"
onClick={() => setShowCreateModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
CREATE NEW TICKET
</button>
<button
className="btn btn-dark"
onClick={() => setShowOverviewModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
QUICK OVERVIEW
</button>
<button
className="btn btn-green"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={{ whiteSpace: 'nowrap' }}
>
{showAdvancedFilters ? '▲ Hide Filters' : '▼ Show Filters'}
</button>
</div>
</div>
<div className="page">
<PageHeader
title="Tickets"
subtitle="Alle Arbeitsauftraege auf einen Blick"
actions={
<>
<Button variant="ghost" onClick={() => setShowOverviewModal(true)}><FaList /> Schnelluebersicht</Button>
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
</>
}
/>
{/* Main Search Bar */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center'
}}>
<input
type="text"
placeholder="WOID"
className="form-control"
style={{ margin: 0 }}
value={localFilters.woid || ''}
onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })}
/>
<input
type="text"
placeholder="Customer"
className="form-control"
style={{ margin: 0 }}
value={localFilters.customer || ''}
onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })}
/>
<input
type="text"
placeholder="User"
className="form-control"
style={{ margin: 0 }}
value={localFilters.userTopic || ''}
onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })}
/>
<button
className="btn btn-green"
onClick={handleApplyFilters}
style={{ margin: 0 }}
>
Apply!
</button>
</div>
{/* Advanced Filters - Collapsible */}
{showAdvancedFilters && (
<div style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid rgba(16, 185, 129, 0.2)'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center',
marginBottom: '16px'
}}>
<input
type="text"
placeholder="Created Date"
className="form-control"
style={{ margin: 0 }}
value={localFilters.createdDate || ''}
onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.type || ''}
onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}
>
<option value="">Type / Location</option>
<option>Home Office</option>
<option>Holidays</option>
<option>Trip</option>
<option>Supportrequest</option>
<option>Change Request</option>
<option>Maintenance</option>
<option>Project</option>
<option>Procurement</option>
<option>Emergency Call</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.system || ''}
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
>
<option value="">System</option>
<option>Client</option>
<option>Server</option>
<option>Network</option>
<option>EDI</option>
<option>TOS</option>
<option>Reports</option>
<option>n/a</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.priority || ''}
onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}
>
<option value="">Priority</option>
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
<option value="4">Critical</option>
</select>
</div>
{/* Quick Selection Buttons */}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
marginBottom: '16px'
}}>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, type: 'Procurement' }))
setFilters(prev => ({ ...prev, type: ['Procurement'] }))
setTimeout(() => refresh(), 0)
}}
>
Procurements
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '4' }))
setFilters(prev => ({ ...prev, priority: [4] }))
setTimeout(() => refresh(), 0)
}}
>
Criticals
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '3' }))
setFilters(prev => ({ ...prev, priority: [3] }))
setTimeout(() => refresh(), 0)
}}
>
Highs
</button>
<div style={{
width: '1px',
height: '32px',
background: 'rgba(16, 185, 129, 0.3)',
margin: '0 8px'
}}></div>
<button
className="btn btn-green"
onClick={() => {
setLimit(10)
setFilters(prev => ({ ...prev, limit: 10 }))
setTimeout(() => refresh(), 0)
}}
>
10
</button>
<button
className="btn btn-green"
onClick={() => {
setLimit(25)
setFilters(prev => ({ ...prev, limit: 25 }))
setTimeout(() => refresh(), 0)
}}
>
25
</button>
</div>
{/* Slider */}
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center'
}}>
<span style={{ color: 'var(--dark-text)', minWidth: '120px' }}>
Load Limit: {limit}
</span>
<div style={{ flex: '1' }}>
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
</div>
</div>
)}
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
<div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
<input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
<input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
<input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
<Button variant="primary" onClick={applyFilters}>Anwenden</Button>
</div>
</div>
<table className="table table-hover">
<tbody>
{loading ? (
<tr>
<td colSpan={10} className="text-center p-2">
<FaSpinner className="spinner" size={32} />
<p>Loading...</p>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={10} className="text-center p-2 text-red">
Error: {error}
</td>
</tr>
) : workorders.length === 0 ? (
<tr>
<td colSpan={10} className="text-center p-2">
No tickets found matching your filters.
</td>
</tr>
) : (
workorders.map(ticket => (
<TicketRow
key={ticket.$id}
ticket={ticket}
onUpdate={handleUpdate}
/>
))
)}
</tbody>
</table>
{showAdvanced && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
<option value="">Typ / Ort</option>
<option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
<option>Project</option><option>Procurement</option><option>Emergency Call</option>
<option>Home Office</option><option>Holidays</option><option>Trip</option>
</select>
<select className="form-control" value={localFilters.system} onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}>
<option value="">System</option>
<option>Client</option><option>Server</option><option>Network</option>
<option>EDI</option><option>TOS</option><option>Reports</option><option>n/a</option>
</select>
<select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
<option value="">Prioritaet</option>
<option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
<option value="3">Hoch</option><option value="4">Kritisch</option>
</select>
</div>
<div style={{ marginTop: 16 }}>
<div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
<button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
<button type="button" onClick={() => setStatusSel(ALL_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Alle inkl. Archiv</button>
</div>
<div className="flex gap-3 wrap">
{ALL_STATUSES.map((status) => (
<label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
<input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
{status}
{ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
</label>
))}
</div>
</div>
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, type: ['Procurement'] })); setTimeout(refresh, 0) }}>Beschaffung</Button>
</div>
</div>
)}
</Card>
{workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<button
className="btn btn-green"
style={{
padding: '16px 32px',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 auto'
}}
onClick={handleLoadMore}
>
Load More <FaAngleDown size={20} />
</button>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<Card><div className="text-red">Fehler: {error}</div></Card>
) : workorders.length === 0 ? (
<Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
) : (
<div className="tickets-list">
{workorders.map((ticket) => (
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
))}
</div>
)}
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
padding: '16px',
marginTop: '24px',
textAlign: 'center',
color: '#a0aec0'
}}>
<p style={{ margin: 0 }}>
Summary: Listed a total of <span style={{
color: 'var(--green-primary)',
fontWeight: 'bold'
}}>{workorders.length}</span> Workorders.
<br />EOL =)
</p>
</div>
{workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
</div>
)}
<CreateTicketModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreate={handleCreate}
customers={customers}
/>
<QuickOverviewModal
isOpen={showOverviewModal}
onClose={() => setShowOverviewModal(false)}
workorders={workorders}
/>
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
<QuickOverviewModal isOpen={showOverviewModal} onClose={() => setShowOverviewModal(false)} workorders={workorders} />
</div>
)
}
+460 -22
View File
@@ -5,21 +5,69 @@
}
:root {
--dark-bg: #1a202c;
--dark-card-bg: rgba(45, 55, 72, 0.95);
--dark-text: #e2e8f0;
--green-primary: #10b981;
--green-secondary: #059669;
/* Surfaces (calm dark) */
--bg: #0e131a;
--surface-1: #151b23;
--surface-2: #1b2430;
--surface-3: #222d3a;
--surface-hover: #243040;
/* Borders / lines */
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--border-accent: rgba(16, 185, 129, 0.35);
/* Text */
--text: #e6edf3;
--text-muted: #9aa7b4;
--text-faint: #6b7785;
/* Accent */
--accent: #10b981;
--accent-2: #059669;
--accent-soft: rgba(16, 185, 129, 0.14);
/* Semantic */
--info: #3b82f6;
--warn: #f59e0b;
--danger: #ef4444;
--ok: #10b981;
/* Spacing */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
/* Radius */
--r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-pill: 999px;
/* Elevation */
--shadow-1: 0 1px 2px rgba(0,0,0,0.3);
--shadow-2: 0 4px 14px rgba(0,0,0,0.3);
--shadow-3: 0 12px 32px rgba(0,0,0,0.4);
/* Legacy aliases (Rueckwaertskompatibilitaet) */
--dark-bg: var(--bg);
--dark-card-bg: var(--surface-2);
--dark-text: var(--text);
--green-primary: var(--accent);
--green-secondary: var(--accent-2);
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--sidebar-w: 248px;
--sidebar-w-collapsed: 72px;
}
body {
font-family: 'Lato', sans-serif;
background-color: var(--dark-bg);
color: var(--dark-text);
font-family: 'Lato', system-ui, -apple-system, sans-serif;
background: radial-gradient(1200px 700px at 80% -10%, rgba(16,185,129,0.06), transparent 60%),
radial-gradient(900px 600px at -10% 110%, rgba(59,130,246,0.05), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar Styling */
@@ -74,8 +122,10 @@ body {
}
.main-content {
margin-top: 64px;
padding: 16px;
padding: var(--sp-5);
max-width: 1320px;
margin: 0 auto;
width: 100%;
}
/* Navbar */
@@ -432,19 +482,19 @@ textarea.form-control {
color: var(--dark-text);
}
/* Status Colors */
.status-open { background: #4CAF50 !important; color: #fff !important; }
.status-occupied { background: #607D8B !important; color: #fff !important; }
.status-assigned { background: #009688 !important; color: #fff !important; }
.status-awaiting { background: #ff9800 !important; color: #fff !important; }
.status-closed { background: #9e9e9e !important; color: #fff !important; }
/* Status Colors (ruhiger, semantisch) */
.status-open { background: rgba(16,185,129,0.18) !important; color: #34d399 !important; }
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
/* Priority Colors */
.priority-none { background: #2196F3 !important; color: #fff !important; }
.priority-low { background: #4CAF50 !important; color: #fff !important; }
.priority-medium { background: #ffc107 !important; color: #000 !important; }
.priority-high { background: #ff9800 !important; color: #fff !important; }
.priority-critical { background: #f44336 !important; color: #fff !important; }
/* Priority Colors (ruhiger, semantisch) */
.priority-none { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
.priority-low { background: rgba(16,185,129,0.16) !important; color: #34d399 !important; }
.priority-medium { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
.priority-high { background: rgba(249,115,22,0.18) !important; color: #fb923c !important; }
.priority-critical { background: rgba(239,68,68,0.18) !important; color: #f87171 !important; }
/* Dropdown */
.dropdown {
@@ -531,6 +581,109 @@ textarea.form-control {
color: #fff;
}
.ticket-projects-overlay { padding: 24px; }
.ticket-projects-modal { width: 100%; max-width: none; min-height: calc(100vh - 48px); box-sizing: border-box; }
.ticket-projects-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.ticket-projects-modal-header h2 { margin: 0; }
.ticket-projects-modal-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
.ticket-projects-modal-section { background: rgba(45,55,72,0.65); border: 1px solid rgba(59,130,246,0.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
.ticket-projects-modal-section h6 { font-weight: bold; margin: 0 0 12px; }
.ticket-projects-modal-side { display: flex; flex-direction: column; }
.ticket-projects-available-list { max-height: min(50vh, 520px); overflow-y: auto; margin-bottom: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 16px; }
.ticket-projects-available-item { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; padding: 4px 0; }
.ticket-projects-create-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; }
@media (max-width: 900px) {
.ticket-projects-modal-grid { grid-template-columns: 1fr; }
.ticket-projects-create-form { grid-template-columns: 1fr; }
.ticket-projects-available-list { grid-template-columns: 1fr; }
}
.ticket-projects-overlay {
padding: 24px;
}
.ticket-projects-modal {
width: 100%;
max-width: none;
min-height: calc(100vh - 48px);
box-sizing: border-box;
}
.ticket-projects-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.ticket-projects-modal-header h2 {
margin: 0;
}
.ticket-projects-modal-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
gap: 24px;
align-items: start;
}
.ticket-projects-modal-section {
background: rgba(45, 55, 72, 0.65);
border: 1px solid rgba(59, 130, 246, 0.25);
border-radius: 12px;
padding: 16px 18px;
margin-bottom: 16px;
}
.ticket-projects-modal-section h6 {
font-weight: bold;
margin: 0 0 12px;
}
.ticket-projects-modal-side {
display: flex;
flex-direction: column;
gap: 0;
}
.ticket-projects-available-list {
max-height: min(50vh, 520px);
overflow-y: auto;
margin-bottom: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 6px 16px;
}
.ticket-projects-available-item {
display: flex;
gap: 8px;
align-items: flex-start;
cursor: pointer;
padding: 4px 0;
}
.ticket-projects-create-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: end;
}
@media (max-width: 900px) {
.ticket-projects-modal-grid {
grid-template-columns: 1fr;
}
.ticket-projects-create-form {
grid-template-columns: 1fr;
}
.ticket-projects-available-list {
grid-template-columns: 1fr;
}
}
.modal-content {
background: rgba(45, 55, 72, 0.98) !important;
border: 1px solid rgba(16, 185, 129, 0.3) !important;
@@ -686,8 +839,293 @@ textarea.form-control {
margin-top: 32px;
}
/* Ticket-Detail Tabs */
.ticket-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
padding-bottom: 8px;
}
.ticket-tab {
background: rgba(45, 55, 72, 0.5);
color: #cbd5e0;
border: 1px solid rgba(16, 185, 129, 0.15);
border-radius: 8px 8px 0 0;
padding: 10px 18px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.ticket-tab:hover {
background: rgba(16, 185, 129, 0.12);
color: var(--dark-text);
}
.ticket-tab-active {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #fff;
border-color: transparent;
}
.ticket-tab-content {
min-height: 120px;
}
/* Responsive */
@media (max-width: 768px) {
.col-6 { width: 100%; }
.navbar-nav { display: none; }
.ticket-tab { padding: 8px 12px; font-size: 13px; }
}
/* ============================================================= */
/* =================== REDESIGN (Designsystem) =============== */
/* ============================================================= */
/* --- App-Shell --- */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.app-scroll { flex: 1; overflow-y: auto; }
/* --- Sidebar (fix, Labels sichtbar, gruppiert) --- */
.side {
width: var(--sidebar-w);
flex-shrink: 0;
background: linear-gradient(180deg, var(--surface-1), var(--bg));
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
}
.side.collapsed { width: var(--sidebar-w-collapsed); }
.side-head {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-4); border-bottom: 1px solid var(--border);
min-height: 64px;
}
.side-logo {
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 800;
}
.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; }
.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); }
.side-group { margin-bottom: var(--sp-4); }
.side-group-label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.7px;
color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-2);
}
.side.collapsed .side-group-label,
.side.collapsed .side-link-label,
.side.collapsed .side-brand { display: none; }
.side-link {
display: flex; align-items: center; gap: var(--sp-3);
padding: 10px var(--sp-3); margin: 2px var(--sp-1);
border-radius: var(--r-sm); color: var(--text-muted);
text-decoration: none; transition: all 0.15s ease; white-space: nowrap;
}
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
.side-link:hover { background: var(--surface-2); color: var(--text); }
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.side-link.active svg { color: var(--accent); }
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
.side-toggle {
background: transparent; border: none; color: var(--text-muted);
cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm);
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
}
.side-toggle:hover { background: var(--surface-2); color: var(--text); }
/* --- Page header + container --- */
.page { padding: var(--sp-5); max-width: 1320px; margin: 0 auto; width: 100%; }
.page-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap;
}
.page-title { font-size: 24px; font-weight: 800; margin: 0; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
/* --- UI Card / Section --- */
.ui-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-1);
}
.ui-card.pad { padding: var(--sp-4); }
.ui-card-head {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--border);
}
.ui-card-head h3 { margin: 0; font-size: 16px; font-weight: 700; }
.ui-card-body { padding: var(--sp-4); }
/* --- Buttons (Redesign-Varianten) --- */
.ui-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid transparent;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
background: var(--surface-2); color: var(--text); text-decoration: none;
}
.ui-btn:hover { background: var(--surface-hover); }
.ui-btn-primary { background: var(--accent); color: #06231a; border-color: transparent; }
.ui-btn-primary:hover { background: #34d399; }
.ui-btn-ghost { background: transparent; border-color: var(--border-strong); color: var(--text-muted); }
.ui-btn-ghost:hover { background: var(--surface-2); color: var(--text); }
.ui-btn-danger { background: rgba(239,68,68,0.15); color: #f87171; }
.ui-btn-danger:hover { background: rgba(239,68,68,0.25); }
.ui-btn-sm { padding: 6px 10px; font-size: 13px; }
.ui-btn:disabled { opacity: 0.55; cursor: not-allowed; }
/* --- Badges / Pills --- */
.badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: var(--r-pill);
font-size: 12px; font-weight: 600; line-height: 1.4;
background: var(--surface-2); color: var(--text-muted);
}
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge-info { background: rgba(59,130,246,0.16); color: #93c5fd; }
.badge-ok { background: rgba(16,185,129,0.16); color: #34d399; }
.badge-warn { background: rgba(245,158,11,0.18); color: #fbbf24; }
.badge-danger { background: rgba(239,68,68,0.18); color: #f87171; }
.badge-muted { background: var(--surface-2); color: var(--text-muted); }
/* --- KPI / Stat cards --- */
.kpi-grid {
display: grid; gap: var(--sp-4);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-bottom: var(--sp-5);
}
.kpi {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--r-md); padding: var(--sp-4);
display: flex; flex-direction: column; gap: 6px; position: relative; overflow: hidden;
}
.kpi-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
.kpi-label { color: var(--text-muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.kpi-value { font-size: 28px; font-weight: 800; }
.kpi-sub { font-size: 12px; color: var(--text-faint); }
/* legacy dashboard stat classes -> new look */
.stats-grid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); margin-bottom: var(--sp-5); }
.stat-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); display: flex; align-items: center; gap: var(--sp-4); }
.stat-info { display: flex; flex-direction: column; }
.stat-value { font-size: 26px; font-weight: 800; }
.stat-label { color: var(--text-muted); font-size: 13px; }
.dashboard-section { margin-top: var(--sp-5); }
.quick-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); }
/* --- Ticket Card (ersetzt Tabelle) --- */
.tickets-list { display: flex; flex-direction: column; gap: var(--sp-3); }
.tcard {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--r-md); overflow: visible; transition: border-color 0.15s ease;
}
.tcard:hover { border-color: var(--border-strong); }
.tcard.open { border-color: var(--border-accent); }
.tcard-detail { border-radius: 0 0 var(--r-md) var(--r-md); }
.tcard-main {
display: grid; grid-template-columns: 92px 1fr auto; gap: var(--sp-4);
padding: var(--sp-4); align-items: center; cursor: pointer;
}
.tcard-woid { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.tcard-woid .id { font-weight: 800; font-size: 15px; color: var(--accent); }
.tcard-woid .age { font-size: 11px; color: var(--text-faint); }
.tcard-body { min-width: 0; }
.tcard-row1 { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: 4px; }
.tcard-customer { font-weight: 700; }
.tcard-loc { color: var(--text-muted); font-size: 13px; }
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
.chevron.up { transform: rotate(180deg); }
@media (max-width: 720px) {
.tcard-main { grid-template-columns: 64px 1fr; }
.tcard-side { grid-column: 1 / -1; justify-content: space-between; }
}
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
.list { display: flex; flex-direction: column; }
.list-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border);
transition: background 0.12s ease; text-decoration: none; color: var(--text);
}
.list-row:last-child { border-bottom: none; }
.list-row:hover { background: var(--surface-2); }
.list-row .grow { flex: 1; min-width: 0; }
.list-row .muted { color: var(--text-muted); font-size: 13px; }
/* --- Clean table (ohne rowSpan-Tricks) --- */
.ui-table { width: 100%; border-collapse: collapse; }
.ui-table th {
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text-faint); font-weight: 600; padding: 10px var(--sp-3);
border-bottom: 1px solid var(--border); background: transparent;
}
.ui-table td { padding: 11px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 14px; }
.ui-table tbody tr:hover td { background: var(--surface-2); }
/* --- Segmented control / toolbar --- */
.toolbar { display: flex; gap: var(--sp-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--sp-4); }
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
.seg button {
border: none; background: transparent; color: var(--text-muted);
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
}
.seg button.active { background: var(--accent-soft); color: var(--accent); }
.search-input { flex: 1; min-width: 200px; }
/* --- Empty state --- */
.empty { text-align: center; color: var(--text-muted); padding: var(--sp-6) var(--sp-4); }
.empty .empty-icon { font-size: 32px; opacity: 0.5; margin-bottom: var(--sp-3); }
/* --- Field --- */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--sp-3); }
.field label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
/* --- Tab restyle (ruhiger) --- */
.ticket-tabs { border-bottom-color: var(--border); }
.ticket-tab { background: var(--surface-2); border-color: var(--border); color: var(--text-muted); }
.ticket-tab:hover { background: var(--surface-hover); color: var(--text); }
.ticket-tab-active { background: var(--accent); color: #06231a; }
/* --- Soften legacy cards/forms --- */
.card { background: var(--surface-1) !important; border: 1px solid var(--border) !important; border-radius: var(--r-md); }
.card-header { background: var(--surface-2); border-bottom: 1px solid var(--border); }
.modern-card { background: var(--surface-1); border: 1px solid var(--border); }
.modern-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); }
.form-control, .form-select { background: var(--surface-2) !important; border: 1px solid var(--border) !important; border-radius: var(--r-sm); }
.form-control:focus, .form-select:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-soft) !important; }
.footer { background: var(--surface-1); border-top: 1px solid var(--border); padding: var(--sp-4); margin-top: 0; }
/* --- Utilities --- */
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: var(--sp-1); } .gap-2 { gap: var(--sp-2); } .gap-3 { gap: var(--sp-3); } .gap-4 { gap: var(--sp-4); }
.grow { flex: 1; min-width: 0; }
.wrap { flex-wrap: wrap; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.automatic-assignment-note {
padding: 12px 14px;
border-left: 2px solid var(--accent);
background: var(--surface-2);
}
.automatic-assignment-note p {
margin: 5px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
+35 -3
View File
@@ -1,6 +1,38 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
// Direkt zu Appwrite (ein Hop weniger als ticket.webklar.com → nginx → Appwrite)
const apiTarget =
env.VITE_APPWRITE_PROXY_TARGET || 'https://appwrite.webklar.com'
return {
plugins: [react()],
server: {
proxy: {
'/v1': {
target: apiTarget,
changeOrigin: true,
secure: true,
timeout: 120_000,
proxyTimeout: 120_000,
cookieDomainRewrite: 'localhost',
configure: (proxy) => {
proxy.on('error', (err, req, res) => {
console.error('[vite proxy /v1]', err.message)
if (res && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
message: `Proxy-Fehler: ${err.message}. Ist ${apiTarget} erreichbar?`,
})
)
}
})
},
},
},
},
}
})