41 Commits

Author SHA1 Message Date
Claude
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
Claude
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
Claude
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
Claude
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
Claude
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
Claude
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
Claude
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
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
Claude
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
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
Claude
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
Claude
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
Claude
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
Claude
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
Claude
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
86fb24b1a9 Update preview config from ticket system 2026-07-12 09:02:34 +00:00
Claude
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
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
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
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 Deploy
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 Deploy
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 Deploy
7d764ce6bc chore: Debug-Instrumentierung aus TicketCard entfernt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:54:28 +00:00
Webklar Deploy
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 Deploy
43a8064ad5 debug: instrument dropdown clipping in TicketCard (temp)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:45:48 +00:00
Webklar Deploy
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 Deploy
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 Deploy
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
4a2e94bc83 passwort anzeigen 2026-05-25 17:10:09 +02:00
Webklar Deploy
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 Deploy
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
8d62e353cb fix 2026-05-22 21:37:08 +02:00
Webklar Deploy
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
73d6c50b8d gitignor 2026-05-22 21:20:13 +02:00
Webklar Deploy
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 Deploy
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 Deploy
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
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
cb110a184b Merge pull request 'better-design' (#4) from better-design into test
Reviewed-on: #4
2026-01-05 23:49:51 +00:00
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 9981 additions and 2125 deletions

5
.env.development Normal file
View File

@@ -0,0 +1,5 @@
# <20>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
.env.example Normal file
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 <20> 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
.env.production Normal file
View File

@@ -0,0 +1,5 @@
# <20>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

4
.gitignore vendored
View File

@@ -1,10 +1,10 @@
# Dependencies # Dependencies
node_modules/ node_modules/
# Environment variables # Environment variables (persönliche Overrides — nicht committen)
.env .env
.env.local .env.local
.env.production .env.*.local
# Build output # Build output
dist/ dist/

9
.webklar-preview.json Normal file
View File

@@ -0,0 +1,9 @@
{
"enabled": false,
"type": "static",
"branch": "test",
"displayName": "tickte-system",
"templateName": "webklar-preview-template",
"projectType": "project",
"public": false
}

77
LOCAL_DEV.md Normal file
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** <20> 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 <20> 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 <20> Verhalten wie nginx auf dem Server.
## Fehler: <20>Failed to load resource<63> / **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`) <20> 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<70>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) <20> dann ist der Proxy ok.
**Notfall ohne Proxy:** In `.env.local` tempor<6F>r `VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1` <20> 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<69>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` <20> 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`

View File

@@ -1,109 +1,109 @@
{ {
"hash": "ba7c7150", "hash": "91e90cf6",
"configHash": "6a220e5a", "configHash": "eaf678ca",
"lockfileHash": "a10f8d29", "lockfileHash": "4b2b8d7d",
"browserHash": "db384cdc", "browserHash": "114e306f",
"optimized": { "optimized": {
"react": { "react": {
"src": "../../react/index.js", "src": "../../react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "d41cd819", "fileHash": "c1394b31",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../react-dom/index.js", "src": "../../react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "02c7f745", "fileHash": "e0cfaa25",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js", "src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "beec6a15", "fileHash": "4e6552af",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../react/jsx-runtime.js", "src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "f6c3e40e", "fileHash": "326b9f6e",
"needsInterop": true "needsInterop": true
}, },
"@tabler/icons-react": { "@tabler/icons-react": {
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs", "src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
"file": "@tabler_icons-react.js", "file": "@tabler_icons-react.js",
"fileHash": "11d925b5", "fileHash": "ef17992e",
"needsInterop": false "needsInterop": false
}, },
"appwrite": { "appwrite": {
"src": "../../appwrite/dist/esm/sdk.js", "src": "../../appwrite/dist/esm/sdk.js",
"file": "appwrite.js", "file": "appwrite.js",
"fileHash": "e5052173", "fileHash": "e3207492",
"needsInterop": false "needsInterop": false
}, },
"clsx": { "clsx": {
"src": "../../clsx/dist/clsx.mjs", "src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js", "file": "clsx.js",
"fileHash": "7fc4f217", "fileHash": "438277fe",
"needsInterop": false "needsInterop": false
}, },
"date-fns": { "date-fns": {
"src": "../../date-fns/esm/index.js", "src": "../../date-fns/esm/index.js",
"file": "date-fns.js", "file": "date-fns.js",
"fileHash": "371588f0", "fileHash": "c8cdb3c6",
"needsInterop": false "needsInterop": false
}, },
"date-fns/locale": { "date-fns/locale": {
"src": "../../date-fns/esm/locale/index.js", "src": "../../date-fns/esm/locale/index.js",
"file": "date-fns_locale.js", "file": "date-fns_locale.js",
"fileHash": "927376cc", "fileHash": "affdbcf7",
"needsInterop": false "needsInterop": false
}, },
"motion/react": { "motion/react": {
"src": "../../motion/dist/es/react.mjs", "src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js", "file": "motion_react.js",
"fileHash": "03c47f11", "fileHash": "d06adb28",
"needsInterop": false "needsInterop": false
}, },
"postprocessing": { "postprocessing": {
"src": "../../postprocessing/build/index.js", "src": "../../postprocessing/build/index.js",
"file": "postprocessing.js", "file": "postprocessing.js",
"fileHash": "6a1af3d4", "fileHash": "a05d80d0",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../react-dom/client.js", "src": "../../react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "13d89711", "fileHash": "35e13db7",
"needsInterop": true "needsInterop": true
}, },
"react-icons/fa": { "react-icons/fa": {
"src": "../../react-icons/fa/index.esm.js", "src": "../../react-icons/fa/index.esm.js",
"file": "react-icons_fa.js", "file": "react-icons_fa.js",
"fileHash": "ce6891d0", "fileHash": "b39d751a",
"needsInterop": false "needsInterop": false
}, },
"react-icons/fa6": { "react-icons/fa6": {
"src": "../../react-icons/fa6/index.esm.js", "src": "../../react-icons/fa6/index.esm.js",
"file": "react-icons_fa6.js", "file": "react-icons_fa6.js",
"fileHash": "836a5ede", "fileHash": "ee91d98a",
"needsInterop": false "needsInterop": false
}, },
"react-router-dom": { "react-router-dom": {
"src": "../../react-router-dom/dist/index.js", "src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js", "file": "react-router-dom.js",
"fileHash": "65b7bd91", "fileHash": "96b74880",
"needsInterop": false "needsInterop": false
}, },
"tailwind-merge": { "tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs", "src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js", "file": "tailwind-merge.js",
"fileHash": "8ea254d4", "fileHash": "422eebfe",
"needsInterop": false "needsInterop": false
}, },
"three": { "three": {
"src": "../../three/build/three.module.js", "src": "../../three/build/three.module.js",
"file": "three.js", "file": "three.js",
"fileHash": "c500eef6", "fileHash": "7ea78180",
"needsInterop": false "needsInterop": false
} }
}, },
@@ -111,20 +111,20 @@
"chunk-IFCYBMKG": { "chunk-IFCYBMKG": {
"file": "chunk-IFCYBMKG.js" "file": "chunk-IFCYBMKG.js"
}, },
"chunk-7VTGDDTZ": { "chunk-DQJKJRV5": {
"file": "chunk-7VTGDDTZ.js" "file": "chunk-DQJKJRV5.js"
}, },
"chunk-TDH2IRYZ": { "chunk-PJEEZAML": {
"file": "chunk-TDH2IRYZ.js" "file": "chunk-PJEEZAML.js"
}, },
"chunk-NMLHVZ76": { "chunk-6PXSGDAH": {
"file": "chunk-NMLHVZ76.js" "file": "chunk-6PXSGDAH.js"
}, },
"chunk-QRULMDK5": { "chunk-DRWLMN53": {
"file": "chunk-QRULMDK5.js" "file": "chunk-DRWLMN53.js"
}, },
"chunk-FSI7PPCM": { "chunk-SJKHQ62W": {
"file": "chunk-FSI7PPCM.js" "file": "chunk-SJKHQ62W.js"
}, },
"chunk-G3PMV62Z": { "chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js" "file": "chunk-G3PMV62Z.js"

File diff suppressed because one or more lines are too long

View File

@@ -256,7 +256,7 @@ import {
weeksToDays, weeksToDays,
yearsToMonths, yearsToMonths,
yearsToQuarters yearsToQuarters
} from "./chunk-FSI7PPCM.js"; } from "./chunk-SJKHQ62W.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
export { export {
add, add,

View File

@@ -11,7 +11,7 @@ import {
requiredArgs, requiredArgs,
startOfUTCWeek, startOfUTCWeek,
toDate toDate
} from "./chunk-FSI7PPCM.js"; } from "./chunk-SJKHQ62W.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js // node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import { import {
require_react_dom require_react_dom
} from "./chunk-TDH2IRYZ.js"; } from "./chunk-PJEEZAML.js";
import "./chunk-QRULMDK5.js"; import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
export default require_react_dom(); export default require_react_dom();
//# sourceMappingURL=react-dom.js.map //# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +1,7 @@
import { import {
require_react_dom require_react_dom
} from "./chunk-TDH2IRYZ.js"; } from "./chunk-PJEEZAML.js";
import "./chunk-QRULMDK5.js"; import "./chunk-DRWLMN53.js";
import { import {
__commonJS __commonJS
} from "./chunk-G3PMV62Z.js"; } from "./chunk-G3PMV62Z.js";

View File

@@ -1,7 +1,7 @@
{ {
"version": 3, "version": 3,
"sources": ["../../react-dom/client.js"], "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;", "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": [] "names": []
} }

View File

@@ -1,7 +1,7 @@
import { import {
GenIcon GenIcon
} from "./chunk-7VTGDDTZ.js"; } from "./chunk-DQJKJRV5.js";
import "./chunk-QRULMDK5.js"; import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa/index.esm.js // node_modules/react-icons/fa/index.esm.js

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import { import {
GenIcon GenIcon
} from "./chunk-7VTGDDTZ.js"; } from "./chunk-DQJKJRV5.js";
import "./chunk-QRULMDK5.js"; import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa6/index.esm.js // node_modules/react-icons/fa6/index.esm.js

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
import { import {
require_react_dom require_react_dom
} from "./chunk-TDH2IRYZ.js"; } from "./chunk-PJEEZAML.js";
import { import {
require_react require_react
} from "./chunk-QRULMDK5.js"; } from "./chunk-DRWLMN53.js";
import { import {
__toESM __toESM
} from "./chunk-G3PMV62Z.js"; } from "./chunk-G3PMV62Z.js";

2
node_modules/.vite/deps/react.js generated vendored
View File

@@ -1,6 +1,6 @@
import { import {
require_react require_react
} from "./chunk-QRULMDK5.js"; } from "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
export default require_react(); export default require_react();
//# sourceMappingURL=react.js.map //# sourceMappingURL=react.js.map

View File

@@ -1,6 +1,6 @@
import { import {
require_react require_react
} from "./chunk-QRULMDK5.js"; } from "./chunk-DRWLMN53.js";
import { import {
__commonJS __commonJS
} from "./chunk-G3PMV62Z.js"; } from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import { import {
require_jsx_runtime require_jsx_runtime
} from "./chunk-NMLHVZ76.js"; } from "./chunk-6PXSGDAH.js";
import "./chunk-QRULMDK5.js"; import "./chunk-DRWLMN53.js";
import "./chunk-G3PMV62Z.js"; import "./chunk-G3PMV62Z.js";
export default require_jsx_runtime(); export default require_jsx_runtime();
//# sourceMappingURL=react_jsx-runtime.js.map //# sourceMappingURL=react_jsx-runtime.js.map

1198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"three": "^0.182.0" "three": "^0.182.0"

View File

@@ -1,24 +1,30 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import Navbar from './components/Navbar' import Sidebar from './components/Navbar'
import Footer from './components/Footer' import Footer from './components/Footer'
import PixelBlast from './components/PixelBlast'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import TicketsPage from './pages/TicketsPage' import TicketsPage from './pages/TicketsPage'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import AssetsPage from './pages/AssetsPage' import AssetsPage from './pages/AssetsPage'
import PlanboardPage from './pages/PlanboardPage' import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage' import ProjectsPage from './pages/ProjectsPage'
import RoadmapsPage from './pages/RoadmapsPage'
import RoadmapDetailPage from './pages/RoadmapDetailPage'
import ReportsPage from './pages/ReportsPage' import ReportsPage from './pages/ReportsPage'
import DocsPage from './pages/DocsPage' import DocsPage from './pages/DocsPage'
import AdminPage from './pages/AdminPage' 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 }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) { if (loading) {
return ( return (
<div className="text-center p-4"> <div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div> <div className="spinner"></div>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
@@ -40,10 +46,17 @@ function AppRoutes() {
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} /> <Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} /> <Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} /> <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="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} /> <Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} /> <Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></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="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} /> <Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} /> <Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
@@ -55,72 +68,19 @@ function AppContent() {
const { user } = useAuth() const { user } = useAuth()
if (!user) { if (!user) {
return ( return <AppRoutes />
<>
{/* 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 }}>
<AppRoutes />
</div>
</>
)
} }
return ( return (
<> <div className="app-shell">
{/* PixelBlast Background für Haupt-App */} <Sidebar />
<div style={{ <div className="app-main">
position: 'fixed', <div className="app-scroll">
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, display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Navbar />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto' }}>
<AppRoutes /> <AppRoutes />
</div> </div>
<Footer /> <Footer />
</div> </div>
</div> </div>
</>
) )
} }

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>
)
}

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 || ''
}

View File

@@ -5,8 +5,8 @@ import { useEmployees } from '../hooks/useEmployees'
// Fallback-Werte falls Config nicht geladen werden kann // Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [ const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', 'Akquise', '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', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services' 'Rollout', 'Emergency Call', 'Other Services'
] ]
@@ -55,8 +55,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
topic: '', topic: '',
requestedBy: '', requestedBy: '',
requestedFor: '', requestedFor: '',
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
startDate: today, startDate: today,
startTime: '', startTime: '',
deadline: today, deadline: today,
@@ -235,30 +233,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group automatic-assignment-note">
<label className="form-label">Assigned To</label> <label className="form-label">Zuweisung</label>
<select <p>
className="form-control" {employees.length > 0
value={formData.assignedTo} ? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
onChange={(e) => { : 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
const userId = e.target.value </p>
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> </div>
</div> </div>

View File

@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
'n/a' '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 }) { export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
const { user } = useAuth() const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', { const today = new Date().toLocaleDateString('de-DE', {
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
const [error, setError] = useState('') const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true) 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 // Reset form wenn Modal geöffnet wird
useEffect(() => { useEffect(() => {
if (isOpen && workorder) { if (isOpen && workorder) {
setFormData({ setFormData({
serviceType: 'Remote', serviceType: 'Remote',
newStatus: workorder.status || 'Open', newStatus: defaultStatus,
newResponseLevel: workorder.responseLevel || '', newResponseLevel: workorder.responseLevel || '',
totalTime: 0, totalTime: 0,
startDate: today, startDate: today,
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">New Status</label> <label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
<select <select
className="form-control" className="form-control"
value={formData.newStatus} value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)} onChange={(e) => handleChange('newStatus', e.target.value)}
required required
> >
{STATUS_OPTIONS.map(status => ( {statusOptions.map(status => (
<option key={status} value={status}>{status}</option> <option key={status} value={status}>{status}</option>
))} ))}
</select> </select>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div> </div>
<div className="form-group"> <div className="form-group">

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>
)
}

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>
)
}

View File

@@ -1,10 +1,12 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { FaTimes, FaSpinner } from 'react-icons/fa' import { FaTimes, FaSpinner } from 'react-icons/fa'
import { storage, BUCKET_ID, ID } from '../lib/appwrite' 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 [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [status, setStatus] = useState('')
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const handleDrop = async (e) => { const handleDrop = async (e) => {
@@ -33,6 +35,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
} }
setUploading(true) setUploading(true)
setStatus('Lade in Ablage hoch...')
try { try {
const response = await storage.createFile( const response = await storage.createFile(
@@ -41,6 +44,30 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
file 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) onUploadComplete?.(response)
onClose() onClose()
} catch (error) { } catch (error) {
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
alert('Error uploading file: ' + error.message) alert('Error uploading file: ' + error.message)
} finally { } finally {
setUploading(false) setUploading(false)
setStatus('')
} }
} }
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
{uploading ? ( {uploading ? (
<div> <div>
<FaSpinner className="spinner" size={32} /> <FaSpinner className="spinner" size={32} />
<p className="mt-1">Uploading file...</p> <p className="mt-1">{status || 'Uploading file...'}</p>
</div> </div>
) : ( ) : (
<> <>

View File

@@ -1,12 +1,8 @@
export default function Footer() { export default function Footer() {
return ( return (
<footer className="footer"> <footer className="footer">
<p> <p className="faint" style={{ fontSize: 13, margin: 0 }}>
WOMS 2.0 - Work Order Management System | Webklar Ticket-Plattform · © {new Date().getFullYear()}
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> </p>
</footer> </footer>
) )

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>
)
}

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' }}> <EFBFBD> {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: '<27>', 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>
)
}

View File

@@ -1,174 +1,128 @@
import { useState } from 'react' 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 { useAuth } from '../context/AuthContext'
import { motion } from 'motion/react'
import { import {
IconTicket, IconTicket,
IconDeviceDesktop, IconUsers,
IconChartBar, IconTargetArrow,
IconCalendar, IconReportMoney,
IconFiles,
IconFolders, IconFolders,
IconFileText, IconCalendar,
IconBook, IconRoute,
IconLayoutDashboard,
IconReport,
IconDeviceDesktop,
IconSettings, IconSettings,
IconLogout IconBook,
IconLogout,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
export default function Navbar() { export default function Sidebar() {
const { user, logout, isAdmin } = useAuth() const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [open, setOpen] = useState(false) const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
navigate('/login') navigate('/login')
} }
const links = [ const groups = [
{ {
label: 'Tickets', label: 'Betrieb',
href: '/tickets', links: [
icon: <IconTicket className="icon" />, { 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', label: 'CRM',
href: '/assets', links: [
icon: <IconDeviceDesktop className="icon" />, { label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
],
}, },
{ {
label: 'Dashboard', label: 'Finanzen',
href: '/dashboard', links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
icon: <IconChartBar className="icon" />,
}, },
{ {
label: 'Planboard', label: 'Dokumente',
href: '/planboard', links: [{ label: 'Dokumente', href: '/documents', icon: <IconFiles /> }],
icon: <IconCalendar className="icon" />,
}, },
{ {
label: 'Projects', label: 'Auswertung',
href: '/projects', links: [
icon: <IconFolders className="icon" />, { label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
{ label: 'Reports', href: '/reports', icon: <IconReport /> },
],
}, },
{ {
label: 'Reports', label: 'System',
href: '/reports', links: [
icon: <IconFileText className="icon" />, { label: 'Assets', href: '/assets', icon: <IconDeviceDesktop /> },
}, ...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
{ { label: 'Docs', href: '/docs', icon: <IconBook /> },
label: 'Docs', ],
href: '/docs',
icon: <IconBook className="icon" />,
}, },
] ]
// Admin link nur wenn isAdmin const isActive = (href) =>
if (isAdmin) { location.pathname === href || location.pathname.startsWith(href + '/')
links.push({
label: 'Admin',
href: '/admin',
icon: <IconSettings className="icon" />,
})
}
return ( return (
<Sidebar open={open} setOpen={setOpen}> <aside className={`side ${collapsed ? 'collapsed' : ''}`}>
<SidebarBody className="justify-between gap-10"> <div className="side-head">
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}> <Link to="/" className="side-logo" title="Webklar">W</Link>
<Logo /> <div className="side-brand">
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> Webklar
{links.map((link, idx) => ( <small>Ticket-Plattform</small>
<SidebarLink key={idx} link={link} /> </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> ))}
<div> </nav>
{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>
)
}
const Logo = () => { <div className="side-foot">
return ( {user && (
<Link <div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
to="/" <div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
style={{ {(user.name || user.email || '?').charAt(0).toUpperCase()}
position: 'relative', </div>
zIndex: 20, <span className="side-link-label text-ellipsis">{user.name || user.email}</span>
display: 'flex', </div>
alignItems: 'center', )}
gap: '0.5rem', <button className="side-toggle" onClick={handleLogout} title="Logout">
padding: '0.25rem 0', <IconLogout size={20} />
fontSize: '0.875rem', <span className="side-link-label">Logout</span>
fontWeight: 'normal', </button>
color: 'var(--text-color, #333)', <button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
textDecoration: 'none' {collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
}} <span className="side-link-label">Einklappen</span>
> </button>
<div style={{ </div>
height: '20px', </aside>
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>
) )
} }

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>
)
}

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>
)
}

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>
)
}

View File

@@ -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>
)
}

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"><EFBFBD> {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>
)
}

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>
)
}

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>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' 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 { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown' import StatusDropdown from './StatusDropdown'
@@ -7,9 +7,14 @@ import PriorityDropdown from './PriorityDropdown'
import EditorDropdown from './EditorDropdown' import EditorDropdown from './EditorDropdown'
import ResponseDropdown from './ResponseDropdown' import ResponseDropdown from './ResponseDropdown'
import CreateWorksheetModal from './CreateWorksheetModal' import CreateWorksheetModal from './CreateWorksheetModal'
import EditWorksheetTimeModal from './EditWorksheetTimeModal'
import StatusHistoryModal from './StatusHistoryModal' import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList' import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats' 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' import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = { const PRIORITY_CLASSES = {
@@ -20,14 +25,6 @@ const PRIORITY_CLASSES = {
4: 'priority-critical' 4: 'priority-critical'
} }
const PRIORITY_LABELS = {
0: 'NONE',
1: 'LOW',
2: 'MEDIUM',
3: 'HIGH',
4: 'CRITICAL'
}
const STATUS_CLASSES = { const STATUS_CLASSES = {
'Open': 'status-open', 'Open': 'status-open',
'Occupied': 'status-occupied', 'Occupied': 'status-occupied',
@@ -43,38 +40,42 @@ const APPROVAL_ICONS = {
'shipping': FaTruck '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 }) { export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true) const [locked, setLocked] = useState(true)
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false) const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimeWorksheet, setEditTimeWorksheet] = useState(null)
const [showHistoryModal, setShowHistoryModal] = useState(false) const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
// Worksheets für dieses Ticket laden (nur wenn expanded)
const { const {
worksheets, worksheets,
loading: worksheetsLoading, loading: worksheetsLoading,
createWorksheet, createWorksheet,
updateWorksheet,
getTotalTime getTotalTime
} = useWorksheets(expanded ? ticket.woid : null) } = 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 createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de }) const elapsed = formatDistanceToNow(createdAt, { locale: de })
const handleStatusChange = (newStatus) => { const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: 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 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 = () => { const toggleLock = () => {
if (locked) { if (locked) {
@@ -88,15 +89,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
const handleCreateWorksheet = async (worksheetData, currentUser) => { const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser) const result = await createWorksheet(worksheetData, currentUser)
// Wenn Status geändert wurde, aktualisiere Work Order
if (result.success && worksheetData.newStatus !== ticket.status) { if (result.success && worksheetData.newStatus !== ticket.status) {
await onUpdate(ticket.$id, { await onUpdate(ticket.$id, {
status: worksheetData.newStatus, status: worksheetData.newStatus,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
}) })
} }
return result return result
} }
@@ -136,16 +134,10 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
{ticket.topic} {ticket.topic}
</td> </td>
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}> <td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<StatusDropdown <StatusDropdown value={ticket.status} onChange={handleStatusChange} />
value={ticket.status}
onChange={handleStatusChange}
/>
</td> </td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}> <td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<PriorityDropdown <PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
value={ticket.priority}
onChange={handlePriorityChange}
/>
</td> </td>
<td <td
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`} className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
@@ -165,20 +157,13 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</tr> </tr>
<tr className="ticket-row"> <tr className="ticket-row">
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}> <td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
<EditorDropdown <EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
value={ticket.assignedTo}
onChange={handleEditorChange}
/>
</td> </td>
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}> <td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
<ResponseDropdown <ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
value={ticket.responseLevel}
onChange={handleResponseChange}
/>
</td> </td>
</tr> </tr>
{expanded && ( {expanded && (
<>
<tr className="worksheet-expansion"> <tr className="worksheet-expansion">
<td colSpan={10} className="worksheet-cell"> <td colSpan={10} className="worksheet-cell">
<div className="card" style={{ <div className="card" style={{
@@ -188,109 +173,63 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
borderTop: 'none' borderTop: 'none'
}}> }}>
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}> <div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
{/* Bento Box Layout: 2 Spalten */}
<div style={{ {/* Tab-Leiste */}
display: 'grid', <div className="ticket-tabs">
gridTemplateColumns: '1fr 1fr', {TABS.map((t) => (
gap: '20px', <button
alignItems: 'stretch' key={t.id}
}}> type="button"
{/* Linke Spalte: Ticket-Beschreibung (50%) */} 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={{ <div style={{
background: 'rgba(45, 55, 72, 0.5)', background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px', borderRadius: '12px',
padding: '20px', padding: '20px',
border: '1px solid rgba(16, 185, 129, 0.2)', border: '1px solid rgba(16, 185, 129, 0.2)',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}> }}>
<h5 style={{ <h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
color: 'var(--dark-text)', Ticket-Beschreibung
fontWeight: 'bold',
marginBottom: '16px',
fontSize: '18px',
flex: '0 0 auto'
}}>
📋 Ticket-Beschreibung
</h5> </h5>
<p style={{ <p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
fontSize: '14px',
lineHeight: '1.8',
color: 'rgba(226, 232, 240, 0.8)',
whiteSpace: 'pre-wrap',
margin: 0,
flex: '1 1 auto',
overflowY: 'auto'
}}>
{ticket.details || 'Keine Details vorhanden.'} {ticket.details || 'Keine Details vorhanden.'}
</p> </p>
</div> </div>
)}
{/* Rechte Spalte: Statistics, Buttons (50%) */} {activeTab === 'worksheets' && (
<div style={{ <div>
display: 'flex', <div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
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 <button
className="btn" className="btn"
style={{ style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white', color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
border: 'none', fontWeight: 'bold', cursor: 'pointer', flex: 1
padding: '12px 20px',
borderRadius: '8px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}} }}
onClick={() => setShowCreateWorksheet(true)} onClick={() => setShowCreateWorksheet(true)}
> >
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet <FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
</button> </button>
{/* History Icon Button - klein, grau, nur Icon */}
<button <button
style={{ type="button"
background: '#616161', style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
color: 'white', onClick={() => setShowProjectsModal(true)}
border: 'none', title="Projekte bearbeiten"
padding: '12px', >
borderRadius: '8px', <FaPen size={18} />
cursor: 'pointer', </button>
transition: 'all 0.2s ease', <button
display: 'flex', type="button"
alignItems: 'center', style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
justifyContent: 'center',
minWidth: '44px',
width: '44px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#757575'
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#616161'
e.currentTarget.style.transform = 'translateY(0)'
}}
onClick={() => setShowHistoryModal(true)} onClick={() => setShowHistoryModal(true)}
title="Status History" title="Status History"
> >
@@ -298,40 +237,38 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</button> </button>
</div> </div>
{/* Statistiken */}
{worksheets.length > 0 && ( {worksheets.length > 0 && (
<div style={{ <div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0
}}>
<WorksheetStats worksheets={worksheets} /> <WorksheetStats worksheets={worksheets} />
</div> </div>
)} )}
</div>
</div>
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */} <WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} />
<div style={{ </div>
marginTop: '20px', )}
width: '100%'
}}> {activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
<WorksheetList
worksheets={worksheets} {activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
totalTime={getTotalTime()}
loading={worksheetsLoading} {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>
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
</>
)} )}
<CreateWorksheetModal <CreateWorksheetModal
@@ -341,12 +278,26 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
onCreate={handleCreateWorksheet} onCreate={handleCreateWorksheet}
/> />
<EditWorksheetTimeModal
isOpen={Boolean(editTimeWorksheet)}
onClose={() => setEditTimeWorksheet(null)}
worksheet={editTimeWorksheet}
onSave={handleSaveWorksheetTime}
/>
<StatusHistoryModal <StatusHistoryModal
isOpen={showHistoryModal} isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)} onClose={() => setShowHistoryModal(false)}
worksheets={worksheets} worksheets={worksheets}
ticket={ticket} ticket={ticket}
/> />
<TicketProjectsModal
isOpen={showProjectsModal}
onClose={() => setShowProjectsModal(false)}
ticket={ticket}
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
/>
<tr className="spacer"> <tr className="spacer">
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td> <td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
</tr> </tr>

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<EFBFBD>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>
)
}

View File

@@ -1,7 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa' 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({}) const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) { if (loading) {
return ( return (
@@ -49,13 +53,14 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
<div className="timeline"> <div className="timeline">
{worksheets.map((ws, index) => { {worksheets.map((ws, index) => {
const isExpanded = expandedWorksheets[ws.wsid] || false const isExpanded = expandedWorksheets[ws.wsid] || false
const isGit = ws.serviceType === 'GIT'
return ( return (
<div key={ws.$id} className="timeline-item mb-4" style={{ <div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards` animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}> }}>
<div className="card border-0 shadow-sm overflow-hidden" style={{ <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', borderRadius: '8px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease' transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => { }} 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" className="card-header d-flex justify-content-between align-items-center py-3"
onClick={() => toggleWorksheet(ws.wsid)} onClick={() => toggleWorksheet(ws.wsid)}
style={{ 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, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)', : 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white', color: 'white',
@@ -80,12 +87,16 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
transition: 'background 0.2s ease' transition: 'background 0.2s ease'
}} }}
onMouseEnter={(e) => { 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, #059669 0%, #047857 100%)'
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)' : 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
}} }}
onMouseLeave={(e) => { 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, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)' : 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
}} }}
@@ -98,13 +109,20 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
)} )}
<div> <div>
<strong className="fs-6">WSID {ws.wsid}</strong> <strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && ( {ws.isComment && !isGit && (
<span className="badge ms-2" style={{ <span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)' background: 'rgba(255,255,255,0.3)'
}}> }}>
<FaComment className="me-1" /> Kommentar <FaComment className="me-1" /> Kommentar
</span> </span>
)} )}
{needsTimeEntry(ws) && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
<FaClock className="me-1" /> Zeit offen
</span>
)}
</div> </div>
{/* Collapsed: Mitarbeiter & Zeit im Header */} {/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && ( {!isExpanded && (
@@ -126,10 +144,32 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
</div> </div>
)} )}
</div> </div>
<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 }}> <small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime)} {formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
</small> </small>
</div> </div>
</div>
{/* Body - Nur wenn expanded */} {/* Body - Nur wenn expanded */}
{isExpanded && ( {isExpanded && (

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>
)
}

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>
)
}

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>
)
}

View File

@@ -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>
)
}

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>
)
}

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>
)
}

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>
)
}

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>
)
}

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
src/components/ui/Kpi.jsx Normal file
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>
)
}

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>
)
}

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>
}

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>
)
}

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'

View File

@@ -1,24 +1,86 @@
import { createContext, useContext, useState, useEffect } from 'react' 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() const AuthContext = createContext()
// Demo mode when Appwrite is not configured const DEMO_MODE = isDemoMode
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// #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 // Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu
async function ensureEmployeeExists(user) { async function ensureEmployeeExists(user) {
if (!user || DEMO_MODE) return if (!user || DEMO_MODE) return
try { try {
// Prüfe ob User bereits in employees Collection existiert
const response = await databases.listDocuments( const response = await databases.listDocuments(
DATABASE_ID, DATABASE_ID,
COLLECTIONS.EMPLOYEES, COLLECTIONS.EMPLOYEES,
[Query.equal('userId', user.$id)] [Query.equal('userId', user.$id)]
) )
// Wenn User noch nicht existiert, füge ihn hinzu
if (response.documents.length === 0) { if (response.documents.length === 0) {
await databases.createDocument( await databases.createDocument(
DATABASE_ID, DATABASE_ID,
@@ -28,13 +90,12 @@ async function ensureEmployeeExists(user) {
userId: user.$id, userId: user.$id,
displayName: user.name || user.email, displayName: user.name || user.email,
email: 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') console.log('✅ User automatisch zur Mitarbeiter-Liste hinzugefügt')
} }
} catch (error) { } catch (error) {
// Fehler ignorieren wenn Collection nicht existiert oder Permissions fehlen
if (error.code !== 404) { if (error.code !== 404) {
console.warn('Could not add user to employees collection:', error.message) console.warn('Could not add user to employees collection:', error.message)
} }
@@ -51,7 +112,6 @@ export function AuthProvider({ children }) {
async function checkUser() { async function checkUser() {
if (DEMO_MODE) { if (DEMO_MODE) {
// Check localStorage for demo session
const demoUser = localStorage.getItem('demo_user') const demoUser = localStorage.getItem('demo_user')
if (demoUser) { if (demoUser) {
setUser(JSON.parse(demoUser)) setUser(JSON.parse(demoUser))
@@ -60,17 +120,23 @@ export function AuthProvider({ children }) {
return return
} }
if (!hasAppwriteSession()) {
setUser(null)
setLoading(false)
return
}
try { try {
const session = await account.get() const session = await account.get()
setUser(session) setUser(session)
// Automatisch zur employees Collection hinzufügen
await ensureEmployeeExists(session) await ensureEmployeeExists(session)
} catch (error) { } 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) { if (error.code !== 401 && error.code !== 404) {
console.error('Unexpected error checking user:', error) console.error('Unexpected error checking user:', error)
} }
if (error.code === 401) {
clearAllLocalAppwriteSessions()
}
setUser(null) setUser(null)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -79,7 +145,6 @@ export function AuthProvider({ children }) {
async function login(email, password) { async function login(email, password) {
if (DEMO_MODE) { if (DEMO_MODE) {
// Demo login - accept any credentials
const demoUser = { $id: 'demo', email, name: email.split('@')[0] } const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
localStorage.setItem('demo_user', JSON.stringify(demoUser)) localStorage.setItem('demo_user', JSON.stringify(demoUser))
setUser(demoUser) setUser(demoUser)
@@ -87,19 +152,12 @@ export function AuthProvider({ children }) {
} }
try { try {
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität const normalizedEmail = email.trim()
try { const normalizedPassword = password.trim()
await account.createEmailSession(email, password) clearStaleAppwriteSessions()
} catch (e) {
// Fallback für ältere API await createSessionWithCleanup(normalizedEmail, normalizedPassword)
if (account.createEmailPasswordSession) {
await account.createEmailPasswordSession(email, password)
} else {
throw e
}
}
// User-Daten laden und automatisch zur employees Collection hinzufügen
const session = await account.get() const session = await account.get()
setUser(session) setUser(session)
await ensureEmployeeExists(session) await ensureEmployeeExists(session)
@@ -109,8 +167,16 @@ export function AuthProvider({ children }) {
console.error('Login error:', error) console.error('Login error:', error)
let errorMessage = error.message || 'Login fehlgeschlagen' let errorMessage = error.message || 'Login fehlgeschlagen'
// Bessere Fehlermeldungen if (error.code === 429 || errorMessage.includes('Rate limit')) {
if (error.code === 401 || errorMessage.includes('Invalid credentials')) { errorMessage = 'Zu viele Login-Versuche. Bitte 1530 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' errorMessage = 'Ungültige Email oder Passwort'
} else if (errorMessage.includes('User not found')) { } else if (errorMessage.includes('User not found')) {
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.' errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
@@ -130,26 +196,23 @@ export function AuthProvider({ children }) {
} }
try { try {
await account.deleteSession('current') await account.deleteSessions()
setUser(null)
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
} finally {
clearAllLocalAppwriteSessions()
setUser(null)
} }
} }
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist const isAdmin = Boolean(user?.labels?.includes('admin'))
const isAdmin = () => {
if (!user) return false
// Prüfe ob Benutzer das "admin" Label hat
return user.labels?.includes('admin') || false
}
const value = { const value = {
user, user,
loading, loading,
login, login,
logout, logout,
isAdmin: isAdmin() isAdmin
} }
return ( return (

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback } from 'react' 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 // Default-Werte für Demo-Modus
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
ticketTypes: [ ticketTypes: [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request', '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', 'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services' '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() { export function useAdminConfig() {
const [config, setConfig] = useState(DEFAULT_CONFIG) const [config, setConfig] = useState(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -56,7 +73,7 @@ export function useAdminConfig() {
systems: doc.systems || DEFAULT_CONFIG.systems, systems: doc.systems || DEFAULT_CONFIG.systems,
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels, responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes, serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
priorities: doc.priorities || DEFAULT_CONFIG.priorities priorities: normalizePriorities(doc.priorities)
}) })
} catch (e) { } catch (e) {
// Config existiert noch nicht (404) - das ist normal, verwende Defaults // Config existiert noch nicht (404) - das ist normal, verwende Defaults
@@ -99,7 +116,9 @@ export function useAdminConfig() {
systems: newConfig.systems, systems: newConfig.systems,
responseLevels: newConfig.responseLevels, responseLevels: newConfig.responseLevels,
serviceTypes: newConfig.serviceTypes, serviceTypes: newConfig.serviceTypes,
priorities: newConfig.priorities priorities: normalizePriorities(newConfig.priorities).map((priority) =>
JSON.stringify(priority)
)
} }
try { try {
@@ -149,4 +168,3 @@ export function useAdminConfig() {
refresh: fetchConfig refresh: fetchConfig
} }
} }

View File

@@ -1,7 +1,19 @@
import { useState, useEffect, useCallback } from 'react' 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 // Demo-Kunden für Testing
const DEMO_CUSTOMERS = [ const DEMO_CUSTOMERS = [
@@ -22,12 +34,17 @@ export function useCustomers() {
} }
try { try {
try {
const response = await listCustomersForAdmin()
setCustomers(response.customers || [])
} catch {
const response = await databases.listDocuments( const response = await databases.listDocuments(
DATABASE_ID, DATABASE_ID,
COLLECTIONS.CUSTOMERS, COLLECTIONS.CUSTOMERS,
[Query.orderAsc('name')] [Query.orderAsc('name')]
) )
setCustomers(response.documents) setCustomers(response.documents || [])
}
setError(null) setError(null)
} catch (err) { } catch (err) {
console.error('Error fetching customers:', err) console.error('Error fetching customers:', err)
@@ -50,20 +67,25 @@ export function useCustomers() {
const createCustomer = async (data) => { const createCustomer = async (data) => {
if (DEMO_MODE) { 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]) setCustomers(prev => [...prev, newCustomer])
return { success: true, data: newCustomer } return { success: true, data: newCustomer }
} }
try { try {
const response = await databases.createDocument( const { password, ...fields } = data
DATABASE_ID, const result = await createCustomerWithPortalAccess({ ...fields, password })
COLLECTIONS.CUSTOMERS, const customer = {
ID.unique(), ...result.customer,
data portalPassword: result.customer?.portalPassword || password || '',
) }
setCustomers(prev => [...prev, response]) setCustomers(prev => [...prev, customer])
return { success: true, data: response } return { success: true, data: customer }
} catch (err) { } catch (err) {
return { success: false, error: err.message } return { success: false, error: err.message }
} }
@@ -71,19 +93,41 @@ export function useCustomers() {
const updateCustomer = async (id, data) => { const updateCustomer = async (id, data) => {
if (DEMO_MODE) { 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 } return { success: true }
} }
try { try {
const response = await databases.updateDocument( const { password, ...fields } = data
DATABASE_ID, const payload = { ...fields }
COLLECTIONS.CUSTOMERS, if (password) payload.password = password
id,
data 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: customer }
return { success: true, data: response }
} catch (err) { } catch (err) {
return { success: false, error: err.message } return { success: false, error: err.message }
} }
@@ -96,11 +140,7 @@ export function useCustomers() {
} }
try { try {
await databases.deleteDocument( await deleteCustomerWithPortalAccess(id)
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
id
)
setCustomers(prev => prev.filter(c => c.$id !== id)) setCustomers(prev => prev.filter(c => c.$id !== id))
return { success: true } return { success: true }
} catch (err) { } catch (err) {
@@ -118,4 +158,3 @@ export function useCustomers() {
deleteCustomer deleteCustomer
} }
} }

View File

@@ -1,47 +1,65 @@
import { useState, useEffect, useCallback } from 'react' import { useCallback, useEffect, useState } from 'react'
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite' 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 const DEMO_MODE = isDemoMode
// Demo-Mitarbeiter für Testing
const DEMO_EMPLOYEES = [ 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() { export function useEmployees() {
const [employees, setEmployees] = useState([]) const [employees, setEmployees] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [syncing, setSyncing] = useState(false)
const fetchEmployees = useCallback(async () => { const fetchEmployees = useCallback(async () => {
setLoading(true) setLoading(true)
try {
if (DEMO_MODE) { if (DEMO_MODE) {
setEmployees(DEMO_EMPLOYEES) setEmployees(DEMO_EMPLOYEES)
setLoading(false) } else {
return
}
try { try {
const response = await databases.listDocuments( const result = await listEmployeesForAdmin()
setEmployees(result.employees || [])
} catch {
const result = await databases.listDocuments(
DATABASE_ID, DATABASE_ID,
COLLECTIONS.EMPLOYEES, COLLECTIONS.EMPLOYEES,
[Query.orderAsc('displayName')] [Query.orderAsc('displayName')]
) )
setEmployees(response.documents) setEmployees(result.documents || [])
}
}
setError(null) setError(null)
} catch (err) { } 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([]) setEmployees([])
setError(null) // Kein Fehler, Collection existiert einfach noch nicht setError(err.message || 'Mitarbeiter konnten nicht geladen werden')
} else {
setError(err.message)
setEmployees([])
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -52,141 +70,48 @@ export function useEmployees() {
}, [fetchEmployees]) }, [fetchEmployees])
const createEmployee = async (data) => { const createEmployee = async (data) => {
if (DEMO_MODE) {
const newEmployee = { ...data, $id: Date.now().toString() }
setEmployees(prev => [...prev, newEmployee])
return { success: true, data: newEmployee }
}
try { try {
// Validierung if (DEMO_MODE) {
if (!data.userId || !data.displayName) { const employee = {
return { success: false, error: 'userId und displayName sind erforderlich' } ...data,
userId: crypto.randomUUID(),
$id: Date.now().toString(),
} }
setEmployees((current) => [...current, employee])
const response = await databases.createDocument( return { success: true, data: employee }
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
ID.unique(),
{
userId: data.userId,
displayName: data.displayName,
email: data.email || '',
shortcode: data.shortcode || ''
} }
) const result = await createEmployeeWithLogin(data)
setEmployees(prev => [...prev, response]) setEmployees((current) => [...current, result.employee])
return { success: true, data: response } return { success: true, data: result.employee }
} catch (err) { } catch (err) {
console.error('Error creating employee:', err)
return { success: false, error: err.message } return { success: false, error: err.message }
} }
} }
const updateEmployee = async (id, data) => { const updateEmployee = async (id, data) => {
try {
if (DEMO_MODE) { if (DEMO_MODE) {
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e)) setEmployees((current) =>
current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee)
)
return { success: true } return { success: true }
} }
const result = await updateEmployeeWithLogin(id, data)
try { setEmployees((current) =>
const response = await databases.updateDocument( current.map((employee) => employee.$id === id ? result.employee : employee)
DATABASE_ID,
COLLECTIONS.EMPLOYEES,
id,
data
) )
setEmployees(prev => prev.map(e => e.$id === id ? response : e)) return { success: true, data: result.employee }
return { success: true, data: response }
} catch (err) { } catch (err) {
console.error('Error updating employee:', err)
return { success: false, error: err.message } return { success: false, error: err.message }
} }
} }
const deleteEmployee = async (id) => { const deleteEmployee = async (id) => {
if (DEMO_MODE) {
setEmployees(prev => prev.filter(e => e.$id !== id))
return { success: true }
}
try { try {
await databases.deleteDocument( if (!DEMO_MODE) await deleteEmployeeWithLogin(id)
DATABASE_ID, setEmployees((current) => current.filter((employee) => employee.$id !== id))
COLLECTIONS.EMPLOYEES,
id
)
setEmployees(prev => prev.filter(e => e.$id !== id))
return { success: true } return { success: true }
} catch (err) { } 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 } return { success: false, error: err.message }
} }
} }
@@ -195,13 +120,9 @@ export function useEmployees() {
employees, employees,
loading, loading,
error, error,
syncing,
refresh: fetchEmployees, refresh: fetchEmployees,
createEmployee, createEmployee,
updateEmployee, updateEmployee,
deleteEmployee, deleteEmployee,
syncWithAuthUsers,
createSelfEmployee
} }
} }

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
src/hooks/useInvoices.js Normal file
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
src/hooks/useLeads.js Normal file
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 }
}

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
src/hooks/useRoadmaps.js Normal file
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 }
}

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 }
}

View File

@@ -1,7 +1,19 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useMemo } 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
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 // Demo data for testing without Appwrite
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
@@ -69,21 +81,34 @@ export function useWorkorders(filters = {}) {
const [workorders, setWorkorders] = useState([]) const [workorders, setWorkorders] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) 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 fetchWorkorders = useCallback(async () => {
const activeFilters = JSON.parse(filtersKey)
setLoading(true) setLoading(true)
if (DEMO_MODE) { if (DEMO_MODE) {
// Filter demo data // Filter demo data
let filtered = [...DEMO_WORKORDERS] let filtered = [...DEMO_WORKORDERS]
if (filters.status?.length > 0) { if (activeFilters.status?.length > 0) {
filtered = filtered.filter(wo => filters.status.includes(wo.status)) filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
} }
if (filters.priority?.length > 0) { if (activeFilters.priority?.length > 0) {
filtered = filtered.filter(wo => filters.priority.includes(wo.priority)) filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
} }
if (filters.limit) { if (activeFilters.woid) {
filtered = filtered.slice(0, filters.limit) filtered = filtered.filter(wo => String(wo.woid) === String(activeFilters.woid))
}
if (activeFilters.limit) {
filtered = filtered.slice(0, activeFilters.limit)
} }
setWorkorders(filtered) setWorkorders(filtered)
setLoading(false) setLoading(false)
@@ -93,39 +118,36 @@ export function useWorkorders(filters = {}) {
try { try {
const queries = [Query.orderDesc('$createdAt')] const queries = [Query.orderDesc('$createdAt')]
if (filters.limit) { if (activeFilters.limit) {
queries.push(Query.limit(filters.limit)) queries.push(Query.limit(activeFilters.limit))
} }
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or() // Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
// Wir filtern clientseitig für mehrere Werte // das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
if (filters.status && filters.status.length > 0) { // Tickets die neuesten Slots belegen und aktive verdraengen).
if (filters.status.length === 1) { if (activeFilters.status && activeFilters.status.length > 0) {
queries.push(Query.equal('status', filters.status[0])) queries.push(Query.equal('status', activeFilters.status))
}
// Für mehrere Werte: Clientseitig filtern (siehe unten)
} }
if (filters.type && filters.type.length > 0) { if (activeFilters.type && activeFilters.type.length > 0) {
if (filters.type.length === 1) { queries.push(Query.equal('type', activeFilters.type))
queries.push(Query.equal('type', filters.type[0]))
}
// Für mehrere Werte: Clientseitig filtern
} }
if (filters.priority && filters.priority.length > 0) { if (activeFilters.priority && activeFilters.priority.length > 0) {
if (filters.priority.length === 1) { queries.push(Query.equal('priority', activeFilters.priority))
queries.push(Query.equal('priority', filters.priority[0]))
}
// Für mehrere Werte: Clientseitig filtern
} }
if (filters.customerId) { if (activeFilters.customerId) {
queries.push(Query.equal('customerId', filters.customerId)) queries.push(Query.equal('customerId', activeFilters.customerId))
} }
if (filters.assignedTo) { if (activeFilters.assignedTo) {
queries.push(Query.equal('assignedTo', filters.assignedTo)) queries.push(Query.equal('assignedTo', activeFilters.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 // Debug: Zeige Collection ID
@@ -142,22 +164,7 @@ export function useWorkorders(filters = {}) {
queries queries
) )
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist) setWorkorders(response.documents)
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)
setError(null) setError(null)
} catch (err) { } catch (err) {
let errorMessage = err.message || 'Fehler beim Laden der Tickets' let errorMessage = err.message || 'Fehler beim Laden der Tickets'
@@ -174,12 +181,60 @@ export function useWorkorders(filters = {}) {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [filters]) }, [filtersKey])
useEffect(() => { useEffect(() => {
fetchWorkorders() fetchWorkorders()
}, [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) => { const createWorkorder = async (data) => {
if (DEMO_MODE) { if (DEMO_MODE) {
// Finde höchste WOID und +1 // Finde höchste WOID und +1
@@ -199,38 +254,16 @@ export function useWorkorders(filters = {}) {
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' } return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
} }
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open" const automatic = await resolveAutomaticTicketFields(data.assignedTo)
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open' const autoStatus = automatic.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();
}
// Bereite Daten für Appwrite vor // Bereite Daten für Appwrite vor
const workorderData = { const workorderData = {
// Required fields // Required fields
topic: data.topic.trim(), 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, 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 // Optional fields - nur senden wenn vorhanden
type: data.type || '', type: data.type || '',
@@ -238,7 +271,8 @@ export function useWorkorders(filters = {}) {
responseLevel: data.responseLevel || '', responseLevel: data.responseLevel || '',
serviceType: data.serviceType || 'Remote', serviceType: data.serviceType || 'Remote',
customerId: data.customerId || '', customerId: data.customerId || '',
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter assignedTo: automatic.assignedTo,
assignedName: automatic.assignedName,
requestedBy: data.requestedBy || '', requestedBy: data.requestedBy || '',
requestedFor: data.requestedFor || '', requestedFor: data.requestedFor || '',
startDate: data.startDate || '', startDate: data.startDate || '',

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react' 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 // Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)

View File

@@ -1,24 +1,29 @@
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite' 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) { if (import.meta.env.DEV) {
console.log('🔧 Appwrite Konfiguration:') console.log('🔧 Appwrite Konfiguration:')
console.log('Endpoint:', import.meta.env.VITE_APPWRITE_ENDPOINT || 'NICHT GESETZT') console.log('Endpoint:', endpoint)
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID || 'NICHT GESETZT') console.log('Project ID:', projectId || 'NICHT GESETZT')
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || '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) { if (!projectId) {
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!') 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() const client = new Client().setEndpoint(endpoint).setProject(projectId)
.setEndpoint(endpoint)
.setProject(projectId)
export const account = new Account(client) export const account = new Account(client)
export const databases = new Databases(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' 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 = { export const COLLECTIONS = {
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders WORKORDERS: 'workorders',
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt) CONFIG: 'config',
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers CUSTOMERS: 'customers',
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees EMPLOYEES: 'employees',
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets WORKSHEETS: 'worksheets',
USERS: 'users', 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 } export { ID, Query }

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' })
}

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' })
}

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
src/lib/leads.js Normal file
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 }
}

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
src/lib/roadmaps.js Normal file
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
src/pages/AdminPage.css Normal file
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; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,40 @@
import { useState } from 'react' import { useState } from 'react'
import { FaServer, FaDesktop, FaPrint, FaNetworkWired } from 'react-icons/fa6' import { FaServer, FaDesktop, FaPrint, FaNetworkWired } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function AssetsPage() { export default function AssetsPage() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
// Placeholder data - would come from Appwrite in production
const assetCategories = [ const assetCategories = [
{ icon: FaServer, name: 'Servers', count: 0 }, { icon: FaServer, name: 'Server', count: 0 },
{ icon: FaDesktop, name: 'Workstations', count: 0 }, { icon: FaDesktop, name: 'Arbeitsplaetze', count: 0 },
{ icon: FaPrint, name: 'Printers', count: 0 }, { icon: FaPrint, name: 'Drucker', count: 0 },
{ icon: FaNetworkWired, name: 'Network Devices', count: 0 } { icon: FaNetworkWired, name: 'Netzwerkgeraete', count: 0 },
] ]
return ( return (
<div className="main-content"> <div className="page">
<header className="text-center mb-2"> <PageHeader title="Assets" subtitle="IT-Inventar und Geraete" />
<h2>Assets Management</h2>
</header>
<div className="search-bar mb-2">
<input <input
type="text" className="form-control"
placeholder="Search assets..." placeholder="Assets suchen..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="input" style={{ maxWidth: 400, marginBottom: 16 }}
style={{ width: '100%', maxWidth: '400px' }}
/> />
</div>
<div className="assets-grid"> <div className="kpi-grid">
{assetCategories.map(({ icon: Icon, name, count }) => ( {assetCategories.map(({ icon: Icon, name, count }) => (
<div key={name} className="asset-card"> <div key={name} className="kpi">
<Icon size={48} className="text-teal" /> <span className="kpi-accent" />
<h4>{name}</h4> <div className="kpi-label"><Icon /> {name}</div>
<span className="asset-count">{count}</span> <div className="kpi-value">{count}</div>
</div> </div>
))} ))}
</div> </div>
<div className="text-center mt-4 text-grey"> <Card><p className="muted" style={{ margin: 0 }}>Asset-Modul - mit Appwrite verbinden, um IT-Assets zu verwalten.</p></Card>
<p>Asset management module - Connect to Appwrite to manage your IT assets.</p>
</div>
</div> </div>
) )
} }

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(' <20> ')}
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} <EFBFBD> {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
src/pages/CustomersPage.jsx Normal file
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 }}><EFBFBD> {c.code}</span>}
</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</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>
)
}

View File

@@ -1,72 +1,104 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' 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() { export default function DashboardPage() {
const [stats, setStats] = useState({ const { summary, loading: finLoading } = useFinanceSummary()
totalTickets: 0, const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
openTickets: 0, const [recent, setRecent] = useState([])
closedTickets: 0, const [docCount, setDocCount] = useState(null)
pendingTickets: 0
})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
async function fetchStats() { let active = true
;(async () => {
try { 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.limit(1)]),
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), 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', '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)]),
]) ])
if (!active) return
setStats({ setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
totalTickets: total.total, setRecent(recentRes.documents)
openTickets: open.total, } catch (e) {
closedTickets: closed.total, console.error(e)
pendingTickets: pending.total
})
} catch (err) {
console.error('Error fetching stats:', err)
} finally { } finally {
setLoading(false) if (active) setLoading(false)
} }
} try {
fetchStats() 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 ( return (
<div className="main-content"> <div className="page">
<header className="text-center mb-2"> <PageHeader
<h2>Dashboard</h2> title="Dashboard"
</header> 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"> <div className="kpi-grid">
<StatCard icon={FaTicket} label="Total Tickets" value={stats.totalTickets} color="#2196F3" /> <Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
<StatCard icon={FaClipboardList} label="Open Tickets" value={stats.openTickets} color="#4CAF50" /> <Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
<StatCard icon={FaClock} label="Pending" value={stats.pendingTickets} color="#FF9800" /> <Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
<StatCard icon={FaCircleCheck} label="Closed" value={stats.closedTickets} color="#9E9E9E" /> <Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
</div> </div>
<div className="dashboard-section mt-4"> <div className="kpi-grid">
<h3>Quick Actions</h3> <Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<div className="quick-actions"> <Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
<a href="/tickets" className="btn btn-teal">View All Tickets</a> <Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<a href="/reports" className="btn btn-dark">Generate Report</a> <Kpi label="Dokumente" value={docCount ?? '...'} accent="var(--info)" />
</div> </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>
</div> </div>
) )

View File

@@ -1,66 +1,27 @@
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6' import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
import { PageHeader, Card } from '../components/ui'
export default function DocsPage() { export default function DocsPage() {
const sections = [ const sections = [
{ { icon: FaBook, title: 'Erste Schritte', content: 'Tickets, Rechnungen und Dokumente in der Plattform verwalten.' },
icon: FaBook, { icon: FaCircleQuestion, title: 'FAQ', content: 'Haeufige Fragen zum System.' },
title: 'Getting Started', { icon: FaKeyboard, title: 'Tipps', content: 'Schneller arbeiten mit der Plattform.' },
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' }
] ]
return ( return (
<div className="main-content"> <div className="page">
<header className="text-center mb-2"> <PageHeader title="Dokumentation" subtitle="Hilfe und Anleitungen" />
<h2>Documentation</h2> <div className="kpi-grid">
</header>
<div className="docs-grid">
{sections.map(({ icon: Icon, title, content }) => ( {sections.map(({ icon: Icon, title, content }) => (
<div key={title} className="doc-card"> <Card key={title}>
<Icon size={32} className="text-teal" /> <div className="flex items-center gap-3" style={{ marginBottom: 8 }}>
<h4>{title}</h4> <Icon size={22} style={{ color: 'var(--accent)' }} />
<p className="text-grey">{content}</p> <strong>{title}</strong>
</div> </div>
<p className="muted" style={{ margin: 0 }}>{content}</p>
</Card>
))} ))}
</div> </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> </div>
) )
} }

111
src/pages/DocumentsPage.jsx Normal file
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
src/pages/FinancePage.jsx Normal file
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: '<27>', 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
src/pages/LeadsPage.jsx Normal file
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>
)
}

View File

@@ -1,46 +1,43 @@
import { useState } from 'react' import { useState } from 'react'
import { format, addDays, startOfWeek } from 'date-fns' 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() { export default function PlanboardPage() {
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 })) const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i)) const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
const navigateWeek = (direction) => {
setCurrentWeek(prev => addDays(prev, direction * 7))
}
return ( return (
<div className="main-content"> <div className="page">
<header className="text-center mb-2"> <PageHeader
<h2>Planboard</h2> title="Planboard"
</header> subtitle="Wochenplanung"
actions={
<div className="planboard-nav text-center mb-2"> <div className="flex gap-2 items-center">
<button className="btn btn-dark" onClick={() => navigateWeek(-1)}> Previous Week</button> <Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
<span className="mx-2"> <span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')} {format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span> </span>
<button className="btn btn-dark" onClick={() => navigateWeek(1)}>Next Week </button> <Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
</div> </div>
}
/>
<div className="planboard-grid"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
{weekDays.map(day => ( {weekDays.map((day) => (
<div key={day.toISOString()} className="planboard-day"> <Card key={day.toISOString()} pad={false}>
<div className="day-header"> <div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<strong>{format(day, 'EEEE')}</strong> <strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
<span>{format(day, 'dd.MM')}</span> <span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
</div>
<div className="day-content">
<p className="text-grey text-small">No tasks scheduled</p>
</div> </div>
<div style={{ padding: 12, minHeight: 120 }}>
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
</div> </div>
</Card>
))} ))}
</div> </div>
<div className="text-center mt-4 text-grey">
<p>Drag and drop tickets to schedule them on the planboard.</p>
</div>
</div> </div>
) )
} }

View File

@@ -1,39 +1,758 @@
import { useState } from 'react' import { useState, useEffect, useMemo } from 'react'
import { FaFolder, FaPlus } from 'react-icons/fa6' 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() { 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 ( return (
<div className="main-content"> <div className="main-content">
<header className="text-center mb-2"> <header className="text-center mb-2">
<h2>Projects</h2> <h2>Website-Projekte</h2>
</header> </header>
<div className="text-center mb-2"> <div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-teal"> <input
<FaPlus /> New Project 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> </button>
</div> </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"> <div className="text-center p-4">
<FaFolder size={64} className="text-grey" /> <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>
) : ( ) : (
<div className="projects-grid"> <div
{projects.map(project => ( style={{
<div key={project.$id} className="project-card"> display: 'grid',
<h4>{project.name}</h4> gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
<p className="text-grey">{project.description}</p> gap: '16px',
<div className="project-meta"> alignItems: 'start',
<span>{project.ticketCount || 0} tickets</span> }}
>
{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> </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>
)}
{/* --- 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>
)} )}
</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>
)
}

View File

@@ -1,81 +1,48 @@
import { useState } from 'react' import { useState } from 'react'
import { format, subDays } from 'date-fns' import { format, subDays } from 'date-fns'
import { FaFileExport, FaChartBar } from 'react-icons/fa6' import { FaFileExport, FaChartBar } from 'react-icons/fa6'
import { PageHeader, Card, Button, Field, EmptyState } from '../components/ui'
export default function ReportsPage() { export default function ReportsPage() {
const [dateRange, setDateRange] = useState({ const [dateRange, setDateRange] = useState({
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'), 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 [reportType, setReportType] = useState('tickets')
const reportTypes = [ const reportTypes = [
{ value: 'tickets', label: 'Ticket Summary' }, { value: 'tickets', label: 'Ticket-Uebersicht' },
{ value: 'performance', label: 'Performance Report' }, { value: 'performance', label: 'Leistungsbericht' },
{ value: 'customer', label: 'Customer Report' }, { value: 'customer', label: 'Kundenbericht' },
{ value: 'technician', label: 'Technician Report' } { value: 'technician', label: 'Techniker-Bericht' },
] ]
const handleGenerateReport = () => { const handleGenerate = () => {
// Would generate report from Appwrite data alert(`Bericht "${reportType}" von ${dateRange.from} bis ${dateRange.to}`)
alert(`Generating ${reportType} report from ${dateRange.from} to ${dateRange.to}`)
} }
return ( return (
<div className="main-content"> <div className="page">
<header className="text-center mb-2"> <PageHeader title="Reports" subtitle="Auswertungen und Exporte" />
<h2>Reports</h2>
</header>
<div className="report-filters card p-2"> <Card title="Bericht erstellen">
<div className="filter-row"> <div className="flex gap-3 wrap">
<label> <Field label="Berichtstyp">
Report Type: <select className="form-control" value={reportType} onChange={(e) => setReportType(e.target.value)}>
<select {reportTypes.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
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>
))}
</select> </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>
<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="filter-row mt-2"> <div style={{ marginTop: 16 }}>
<label> <Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
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>
<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> </div>
</div> </div>
) )

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
src/pages/RoadmapsPage.jsx Normal file
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>
)
}

View File

@@ -1,43 +1,58 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { format } from 'date-fns' import { useSearchParams } from 'react-router-dom'
import { FaAngleDown, FaSpinner } from 'react-icons/fa6' import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
import { useWorkorders } from '../hooks/useWorkorders' import { useWorkorders } from '../hooks/useWorkorders'
import { useCustomers } from '../hooks/useCustomers' import { useCustomers } from '../hooks/useCustomers'
import TicketRow from '../components/TicketRow' import TicketCard from '../components/TicketCard'
import CreateTicketModal from '../components/CreateTicketModal' import CreateTicketModal from '../components/CreateTicketModal'
import QuickOverviewModal from '../components/QuickOverviewModal' 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() { export default function TicketsPage() {
const [limit, setLimit] = useState(10) const [limit, setLimit] = useState(10)
// Aktive Filter (werden für API-Calls verwendet) const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
const [filters, setFilters] = useState({ const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
type: [],
priority: [],
limit: 10
})
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
const [localFilters, setLocalFilters] = useState({ const [localFilters, setLocalFilters] = useState({
woid: '', woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
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 { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers() const { customers } = useCustomers()
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showOverviewModal, setShowOverviewModal] = useState(false) const [showOverviewModal, setShowOverviewModal] = useState(false)
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const [searchParams] = useSearchParams()
const handleApplyFilters = () => { // Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
// Wende lokale Filter auf aktive Filter an // (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
setFilters(prev => ({ 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, ...prev,
status: statusSel.length ? statusSel : ALL_STATUSES,
woid: localFilters.woid || undefined, woid: localFilters.woid || undefined,
customer: localFilters.customer || undefined, customer: localFilters.customer || undefined,
userTopic: localFilters.userTopic || undefined, userTopic: localFilters.userTopic || undefined,
@@ -45,380 +60,112 @@ export default function TicketsPage() {
type: localFilters.type ? [localFilters.type] : [], type: localFilters.type ? [localFilters.type] : [],
system: localFilters.system ? [localFilters.system] : [], system: localFilters.system ? [localFilters.system] : [],
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [], priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
limit: limit limit,
})) }))
} }
const handleLimitChange = (e) => { const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
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 handleCreate = async (data) => { const handleCreate = async (data) => {
const result = await createWorkorder(data) const result = await createWorkorder(data)
if (result.success) { if (result.success) setShowCreateModal(false)
setShowCreateModal(false)
}
return result return result
} }
const loadMore = () => {
const handleLoadMore = () => { const next = limit + 10
setLimit(prev => prev + 10) setLimit(next)
setFilters(prev => ({ ...prev, limit: prev.limit + 10 })) setFilters((prev) => ({ ...prev, limit: next }))
} }
return ( return (
<div className="main-content"> <div className="page">
{/* Sticky Header Container */} <PageHeader
<div style={{ title="Tickets"
position: 'sticky', subtitle="Alle Arbeitsauftraege auf einen Blick"
top: 0, actions={
zIndex: 100, <>
marginBottom: '24px' <Button variant="ghost" onClick={() => setShowOverviewModal(true)}><FaList /> Schnelluebersicht</Button>
}}> <Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
{/* Compact Control Panel */} <Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
<div style={{ </>
background: 'rgba(26, 32, 44, 0.4)', }
backdropFilter: 'blur(25px) saturate(180%)', />
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
borderRadius: '12px', <Card pad className="mb-2" style={{ marginBottom: 16 }}>
border: '1px solid rgba(16, 185, 129, 0.3)', <div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
overflow: 'hidden', <input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
padding: '16px', <input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)', <input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
marginBottom: '16px' <Button variant="primary" onClick={applyFilters}>Anwenden</Button>
}}>
{/* 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>
{/* Main Search Bar */} {showAdvanced && (
<div style={{ <div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
display: 'grid', <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', <input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
gap: '12px', <select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
alignItems: 'center' <option value="">Typ / Ort</option>
}}> <option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
<input <option>Project</option><option>Procurement</option><option>Emergency Call</option>
type="text" <option>Home Office</option><option>Holidays</option><option>Trip</option>
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>
<select <select className="form-control" value={localFilters.system} onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}>
className="form-control"
style={{ margin: 0 }}
value={localFilters.system || ''}
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
>
<option value="">System</option> <option value="">System</option>
<option>Client</option> <option>Client</option><option>Server</option><option>Network</option>
<option>Server</option> <option>EDI</option><option>TOS</option><option>Reports</option><option>n/a</option>
<option>Network</option>
<option>EDI</option>
<option>TOS</option>
<option>Reports</option>
<option>n/a</option>
</select> </select>
<select <select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
className="form-control" <option value="">Prioritaet</option>
style={{ margin: 0 }} <option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
value={localFilters.priority || ''} <option value="3">Hoch</option><option value="4">Kritisch</option>
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> </select>
</div> </div>
<div style={{ marginTop: 16 }}>
{/* Quick Selection Buttons */} <div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
<div style={{ <span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
display: 'flex', <button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
gap: '8px', <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>
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> </div>
<div className="flex gap-3 wrap">
{/* Slider */} {ALL_STATUSES.map((status) => (
<div style={{ <label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
display: 'flex', <input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
gap: '16px', {status}
alignItems: 'center' {ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
}}> </label>
<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> </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> </div>
)} )}
</div> </Card>
</div>
<table className="table table-hover">
<tbody>
{loading ? ( {loading ? (
<tr> <div className="empty"><FaSpinner className="spinner" /></div>
<td colSpan={10} className="text-center p-2">
<FaSpinner className="spinner" size={32} />
<p>Loading...</p>
</td>
</tr>
) : error ? ( ) : error ? (
<tr> <Card><div className="text-red">Fehler: {error}</div></Card>
<td colSpan={10} className="text-center p-2 text-red">
Error: {error}
</td>
</tr>
) : workorders.length === 0 ? ( ) : workorders.length === 0 ? (
<tr> <Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
<td colSpan={10} className="text-center p-2">
No tickets found matching your filters.
</td>
</tr>
) : ( ) : (
workorders.map(ticket => ( <div className="tickets-list">
<TicketRow {workorders.map((ticket) => (
key={ticket.$id} <TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
ticket={ticket} ))}
onUpdate={handleUpdate} </div>
/>
))
)} )}
</tbody>
</table>
{workorders.length > 0 && workorders.length >= limit && ( {workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: '24px' }}> <div style={{ textAlign: 'center', marginTop: 24 }}>
<button <Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></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>
</div> </div>
)} )}
<div style={{ <CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
background: 'rgba(45, 55, 72, 0.95)', <QuickOverviewModal isOpen={showOverviewModal} onClose={() => setShowOverviewModal(false)} workorders={workorders} />
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>
<CreateTicketModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreate={handleCreate}
customers={customers}
/>
<QuickOverviewModal
isOpen={showOverviewModal}
onClose={() => setShowOverviewModal(false)}
workorders={workorders}
/>
</div> </div>
) )
} }

View File

@@ -5,21 +5,69 @@
} }
:root { :root {
--dark-bg: #1a202c; /* Surfaces (calm dark) */
--dark-card-bg: rgba(45, 55, 72, 0.95); --bg: #0e131a;
--dark-text: #e2e8f0; --surface-1: #151b23;
--green-primary: #10b981; --surface-2: #1b2430;
--green-secondary: #059669; --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-700: #374151;
--gray-800: #1f2937; --gray-800: #1f2937;
--gray-900: #111827; --gray-900: #111827;
--sidebar-w: 248px;
--sidebar-w-collapsed: 72px;
} }
body { body {
font-family: 'Lato', sans-serif; font-family: 'Lato', system-ui, -apple-system, sans-serif;
background-color: var(--dark-bg); background: radial-gradient(1200px 700px at 80% -10%, rgba(16,185,129,0.06), transparent 60%),
color: var(--dark-text); 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; overflow: hidden;
-webkit-font-smoothing: antialiased;
} }
/* Scrollbar Styling */ /* Scrollbar Styling */
@@ -74,8 +122,10 @@ body {
} }
.main-content { .main-content {
margin-top: 64px; padding: var(--sp-5);
padding: 16px; max-width: 1320px;
margin: 0 auto;
width: 100%;
} }
/* Navbar */ /* Navbar */
@@ -432,19 +482,19 @@ textarea.form-control {
color: var(--dark-text); color: var(--dark-text);
} }
/* Status Colors */ /* Status Colors (ruhiger, semantisch) */
.status-open { background: #4CAF50 !important; color: #fff !important; } .status-open { background: rgba(16,185,129,0.18) !important; color: #34d399 !important; }
.status-occupied { background: #607D8B !important; color: #fff !important; } .status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
.status-assigned { background: #009688 !important; color: #fff !important; } .status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
.status-awaiting { background: #ff9800 !important; color: #fff !important; } .status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
.status-closed { background: #9e9e9e !important; color: #fff !important; } .status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
/* Priority Colors */ /* Priority Colors (ruhiger, semantisch) */
.priority-none { background: #2196F3 !important; color: #fff !important; } .priority-none { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
.priority-low { background: #4CAF50 !important; color: #fff !important; } .priority-low { background: rgba(16,185,129,0.16) !important; color: #34d399 !important; }
.priority-medium { background: #ffc107 !important; color: #000 !important; } .priority-medium { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
.priority-high { background: #ff9800 !important; color: #fff !important; } .priority-high { background: rgba(249,115,22,0.18) !important; color: #fb923c !important; }
.priority-critical { background: #f44336 !important; color: #fff !important; } .priority-critical { background: rgba(239,68,68,0.18) !important; color: #f87171 !important; }
/* Dropdown */ /* Dropdown */
.dropdown { .dropdown {
@@ -531,6 +581,109 @@ textarea.form-control {
color: #fff; 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 { .modal-content {
background: rgba(45, 55, 72, 0.98) !important; background: rgba(45, 55, 72, 0.98) !important;
border: 1px solid rgba(16, 185, 129, 0.3) !important; border: 1px solid rgba(16, 185, 129, 0.3) !important;
@@ -686,8 +839,293 @@ textarea.form-control {
margin-top: 32px; 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 */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.col-6 { width: 100%; } .col-6 { width: 100%; }
.navbar-nav { display: none; } .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;
} }

View File

@@ -1,6 +1,38 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ 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()], 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?`,
})
)
}
})
},
},
},
},
}
}) })