Compare commits
42 Commits
appwrite-v
...
52713fb481
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52713fb481 | ||
|
|
e59a066d3c | ||
|
|
fe3e39bd04 | ||
| 4c305fcd46 | |||
|
|
0b24596f15 | ||
| 1cec319fa8 | |||
|
|
5786eed3ba | ||
|
|
5270084ff0 | ||
|
|
8855fe92ba | ||
|
|
612063455e | ||
|
|
9e4c60fdaf | ||
| 86fb24b1a9 | |||
|
|
6c3f03b5b4 | ||
| 42f729abfc | |||
| ff536b048f | |||
| 4be20237a1 | |||
|
|
f76b738f5f | ||
|
|
27b365622e | ||
|
|
a1cb90372f | ||
|
|
7d764ce6bc | ||
|
|
1006824375 | ||
|
|
43a8064ad5 | ||
|
|
d20a4de117 | ||
|
|
8f6927fbb0 | ||
|
|
8abf11ad18 | ||
| 4a2e94bc83 | |||
|
|
fcd13e6a40 | ||
|
|
fda673702e | ||
| 8d62e353cb | |||
|
|
f5257cd251 | ||
| 73d6c50b8d | |||
|
|
f174421c63 | ||
|
|
d2e7088146 | ||
|
|
4f4de7f290 | ||
| 5fbb2fb4b5 | |||
| a4c64b5398 | |||
| cb110a184b | |||
| 99b89bcabe | |||
| 895c55399f | |||
| ee7c866616 | |||
| 5717612db5 | |||
| 3f8fce3c02 |
5
.env.development
Normal file
5
.env.development
Normal 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
14
.env.example
Normal 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
5
.env.production
Normal 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
4
.gitignore
vendored
@@ -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
9
.webklar-preview.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "static",
|
||||||
|
"branch": "test",
|
||||||
|
"displayName": "tickte-system",
|
||||||
|
"templateName": "webklar-preview-template",
|
||||||
|
"projectType": "project",
|
||||||
|
"public": false
|
||||||
|
}
|
||||||
21
HETZNER_MESSAGE_PRAEVENTION.md
Normal file
21
HETZNER_MESSAGE_PRAEVENTION.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Sehr geehrtes Hetzner Team,
|
||||||
|
|
||||||
|
bezüglich der Portscan Erkennung haben wir folgende Präventionsmaßnahmen implementiert.
|
||||||
|
|
||||||
|
Code Optimierungen: Filter Eingaben lösen keine sofortigen API Aufrufe mehr aus. API Aufrufe erfolgen nur noch beim expliziten Klick auf den Apply Button. Dies reduziert unnötige TCP Verbindungen um etwa 90 Prozent.
|
||||||
|
|
||||||
|
Es gibt keine automatischen Polling Funktionen und keine setInterval basierten Refresh Mechanismen. API Aufrufe erfolgen nur bei Benutzerinteraktionen.
|
||||||
|
|
||||||
|
Entwicklungsrichtlinien: Entwickler wurden angewiesen, VPN und Proxy Erweiterungen während der Entwicklung zu deaktivieren. Security Plugins werden vor dem Testen überprüft.
|
||||||
|
|
||||||
|
Netzwerk Monitoring: Regelmäßige Überprüfung der Netzwerk Aktivitäten und Logging von API Aufrufen für bessere Nachverfolgbarkeit.
|
||||||
|
|
||||||
|
Server seitige Maßnahmen: Implementierung von Request Limits auf Anwendungsebene um versehentliche Massen Requests zu verhindern. Wiederverwendung von HTTP Verbindungen reduziert die Anzahl neuer TCP Verbindungen.
|
||||||
|
|
||||||
|
Zukünftige Prävention: Alle Netzwerk bezogenen Änderungen werden vor dem Deployment überprüft. Automatische Tests für Netzwerk Verhalten werden durchgeführt. Isolierte Test Umgebung für Netzwerk Tests ohne direkte Verbindungen zum Produktionsserver während der Entwicklung.
|
||||||
|
|
||||||
|
Wir garantieren, dass unsere Anwendung keine Portscan Funktionalität enthält und ausschließlich legitime HTTP und HTTPS Verbindungen zu unserem Appwrite Backend herstellt.
|
||||||
|
|
||||||
|
Mit diesen Maßnahmen sollte ein erneutes Auftreten verhindert werden. Wir bitten um Entsperrung unserer IP Adresse 91.99.156.85.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
15
HETZNER_MESSAGE_URACHE.md
Normal file
15
HETZNER_MESSAGE_URACHE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Sehr geehrtes Hetzner Team,
|
||||||
|
|
||||||
|
bezüglich der Portscan Erkennung von unserer IP Adresse 91.99.156.85 am 30.12.2025 um 10:59:37 UTC möchten wir die Ursache erläutern.
|
||||||
|
|
||||||
|
Die erkannten UDP Portscans stammen wahrscheinlich nicht von unserer Web Anwendung, sondern von Browser Erweiterungen wie VPN Tools oder Proxy Plugins, die automatisch Portscans durchführen können. Diese laufen im Hintergrund und sind dem Benutzer oft nicht bewusst.
|
||||||
|
|
||||||
|
Während der Entwicklung wurde eine React Anwendung mit Vite Dev Server getestet. Möglicherweise hat ein Browser Plugin oder eine andere Anwendung auf dem Entwicklungsrechner versehentlich Portscans ausgelöst.
|
||||||
|
|
||||||
|
Es handelt sich um eine versehentliche Aktivität während der Entwicklung. Es gab keine absichtliche Portscan Aktivität oder Angriffsversuche.
|
||||||
|
|
||||||
|
Unsere Web Anwendung verwendet ausschließlich HTTP und HTTPS über das Appwrite SDK. Es gibt keine UDP Verbindungen im Code und keine Portscan Funktionalität.
|
||||||
|
|
||||||
|
Wir bitten um Entsperrung unserer IP Adresse 91.99.156.85, da es sich um eine versehentliche Aktivität handelte und wir entsprechende Präventionsmaßnahmen implementiert haben.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen
|
||||||
77
LOCAL_DEV.md
Normal file
77
LOCAL_DEV.md
Normal 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`
|
||||||
17
node_modules/.bin/baseline-browser-mapping
generated
vendored
17
node_modules/.bin/baseline-browser-mapping
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../baseline-browser-mapping/dist/cli.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../baseline-browser-mapping/dist/cli.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/browserslist
generated
vendored
17
node_modules/.bin/browserslist
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../browserslist/cli.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../browserslist/cli.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/esbuild
generated
vendored
17
node_modules/.bin/esbuild
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../esbuild/bin/esbuild
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../esbuild/bin/esbuild" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/jsesc
generated
vendored
17
node_modules/.bin/jsesc
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../jsesc/bin/jsesc
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../jsesc/bin/jsesc" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/json5
generated
vendored
17
node_modules/.bin/json5
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../json5/lib/cli.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../json5/lib/cli.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/loose-envify
generated
vendored
17
node_modules/.bin/loose-envify
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../loose-envify/cli.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../loose-envify/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../loose-envify/cli.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/nanoid
generated
vendored
17
node_modules/.bin/nanoid
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../nanoid/bin/nanoid.cjs
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/parser
generated
vendored
17
node_modules/.bin/parser
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../@babel/parser/bin/babel-parser.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/rollup
generated
vendored
17
node_modules/.bin/rollup
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../rollup/dist/bin/rollup
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/semver
generated
vendored
17
node_modules/.bin/semver
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../semver/bin/semver.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../semver/bin/semver.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/update-browserslist-db
generated
vendored
17
node_modules/.bin/update-browserslist-db
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../update-browserslist-db/cli.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
17
node_modules/.bin/vite
generated
vendored
17
node_modules/.bin/vite
generated
vendored
@@ -1 +1,16 @@
|
|||||||
../vite/bin/vite.js
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
56
node_modules/.package-lock.json
generated
vendored
56
node_modules/.package-lock.json
generated
vendored
@@ -32,6 +32,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -275,17 +276,17 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -350,17 +351,30 @@
|
|||||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.53.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
|
||||||
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
|
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
|
"version": "4.53.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
|
||||||
|
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@tabler/icons": {
|
"node_modules/@tabler/icons": {
|
||||||
@@ -447,6 +461,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -523,6 +538,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -799,20 +815,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1133,6 +1135,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -1144,6 +1147,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -1299,7 +1303,8 @@
|
|||||||
"version": "0.182.0",
|
"version": "0.182.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
@@ -1347,6 +1352,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
142
node_modules/.vite/deps/_metadata.json
generated
vendored
142
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,125 +1,125 @@
|
|||||||
{
|
{
|
||||||
"hash": "d78f2739",
|
"hash": "91e90cf6",
|
||||||
"configHash": "b1cb6c91",
|
"configHash": "eaf678ca",
|
||||||
"lockfileHash": "4b2b8d7d",
|
"lockfileHash": "4b2b8d7d",
|
||||||
"browserHash": "9a736d5a",
|
"browserHash": "114e306f",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "2dd90c89",
|
"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": "8229fa46",
|
"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": "6db368d6",
|
"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": "a8fbd3f7",
|
"fileHash": "326b9f6e",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"appwrite": {
|
|
||||||
"src": "../../appwrite/dist/esm/sdk.js",
|
|
||||||
"file": "appwrite.js",
|
|
||||||
"fileHash": "a2fa5c99",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"date-fns": {
|
|
||||||
"src": "../../date-fns/esm/index.js",
|
|
||||||
"file": "date-fns.js",
|
|
||||||
"fileHash": "157aa30f",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"date-fns/locale": {
|
|
||||||
"src": "../../date-fns/esm/locale/index.js",
|
|
||||||
"file": "date-fns_locale.js",
|
|
||||||
"fileHash": "cd94d345",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"react-dom/client": {
|
|
||||||
"src": "../../react-dom/client.js",
|
|
||||||
"file": "react-dom_client.js",
|
|
||||||
"fileHash": "8948be38",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"react-icons/fa": {
|
|
||||||
"src": "../../react-icons/fa/index.esm.js",
|
|
||||||
"file": "react-icons_fa.js",
|
|
||||||
"fileHash": "c064e92c",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"react-icons/fa6": {
|
|
||||||
"src": "../../react-icons/fa6/index.esm.js",
|
|
||||||
"file": "react-icons_fa6.js",
|
|
||||||
"fileHash": "0e64eca2",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"react-router-dom": {
|
|
||||||
"src": "../../react-router-dom/dist/index.js",
|
|
||||||
"file": "react-router-dom.js",
|
|
||||||
"fileHash": "0004d718",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"motion/react": {
|
|
||||||
"src": "../../motion/dist/es/react.mjs",
|
|
||||||
"file": "motion_react.js",
|
|
||||||
"fileHash": "42596b33",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"@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": "14b4db2f",
|
"fileHash": "ef17992e",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"appwrite": {
|
||||||
|
"src": "../../appwrite/dist/esm/sdk.js",
|
||||||
|
"file": "appwrite.js",
|
||||||
|
"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": "87b6d9e4",
|
"fileHash": "438277fe",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"tailwind-merge": {
|
"date-fns": {
|
||||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
"src": "../../date-fns/esm/index.js",
|
||||||
"file": "tailwind-merge.js",
|
"file": "date-fns.js",
|
||||||
"fileHash": "adee159c",
|
"fileHash": "c8cdb3c6",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"three": {
|
"date-fns/locale": {
|
||||||
"src": "../../three/build/three.module.js",
|
"src": "../../date-fns/esm/locale/index.js",
|
||||||
"file": "three.js",
|
"file": "date-fns_locale.js",
|
||||||
"fileHash": "f10da6a8",
|
"fileHash": "affdbcf7",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"motion/react": {
|
||||||
|
"src": "../../motion/dist/es/react.mjs",
|
||||||
|
"file": "motion_react.js",
|
||||||
|
"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": "205bd067",
|
"fileHash": "a05d80d0",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"react-dom/client": {
|
||||||
|
"src": "../../react-dom/client.js",
|
||||||
|
"file": "react-dom_client.js",
|
||||||
|
"fileHash": "35e13db7",
|
||||||
|
"needsInterop": true
|
||||||
|
},
|
||||||
|
"react-icons/fa": {
|
||||||
|
"src": "../../react-icons/fa/index.esm.js",
|
||||||
|
"file": "react-icons_fa.js",
|
||||||
|
"fileHash": "b39d751a",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"react-icons/fa6": {
|
||||||
|
"src": "../../react-icons/fa6/index.esm.js",
|
||||||
|
"file": "react-icons_fa6.js",
|
||||||
|
"fileHash": "ee91d98a",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"react-router-dom": {
|
||||||
|
"src": "../../react-router-dom/dist/index.js",
|
||||||
|
"file": "react-router-dom.js",
|
||||||
|
"fileHash": "96b74880",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"tailwind-merge": {
|
||||||
|
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||||
|
"file": "tailwind-merge.js",
|
||||||
|
"fileHash": "422eebfe",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"src": "../../three/build/three.module.js",
|
||||||
|
"file": "three.js",
|
||||||
|
"fileHash": "7ea78180",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chunks": {
|
"chunks": {
|
||||||
"chunk-DQJKJRV5": {
|
|
||||||
"file": "chunk-DQJKJRV5.js"
|
|
||||||
},
|
|
||||||
"chunk-IFCYBMKG": {
|
"chunk-IFCYBMKG": {
|
||||||
"file": "chunk-IFCYBMKG.js"
|
"file": "chunk-IFCYBMKG.js"
|
||||||
},
|
},
|
||||||
"chunk-6PXSGDAH": {
|
"chunk-DQJKJRV5": {
|
||||||
"file": "chunk-6PXSGDAH.js"
|
"file": "chunk-DQJKJRV5.js"
|
||||||
},
|
},
|
||||||
"chunk-PJEEZAML": {
|
"chunk-PJEEZAML": {
|
||||||
"file": "chunk-PJEEZAML.js"
|
"file": "chunk-PJEEZAML.js"
|
||||||
},
|
},
|
||||||
|
"chunk-6PXSGDAH": {
|
||||||
|
"file": "chunk-6PXSGDAH.js"
|
||||||
|
},
|
||||||
"chunk-DRWLMN53": {
|
"chunk-DRWLMN53": {
|
||||||
"file": "chunk-DRWLMN53.js"
|
"file": "chunk-DRWLMN53.js"
|
||||||
},
|
},
|
||||||
|
|||||||
3
node_modules/@esbuild/darwin-arm64/README.md
generated
vendored
3
node_modules/@esbuild/darwin-arm64/README.md
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# esbuild
|
|
||||||
|
|
||||||
This is the macOS ARM 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
|
|
||||||
BIN
node_modules/@esbuild/darwin-arm64/bin/esbuild
generated
vendored
BIN
node_modules/@esbuild/darwin-arm64/bin/esbuild
generated
vendored
Binary file not shown.
20
node_modules/@esbuild/darwin-arm64/package.json
generated
vendored
20
node_modules/@esbuild/darwin-arm64/package.json
generated
vendored
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@esbuild/darwin-arm64",
|
|
||||||
"version": "0.21.5",
|
|
||||||
"description": "The macOS ARM 64-bit binary for esbuild, a JavaScript bundler.",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/evanw/esbuild.git"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"preferUnplugged": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
3
node_modules/@rollup/rollup-darwin-arm64/README.md
generated
vendored
3
node_modules/@rollup/rollup-darwin-arm64/README.md
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# `@rollup/rollup-darwin-arm64`
|
|
||||||
|
|
||||||
This is the **aarch64-apple-darwin** binary for `rollup`
|
|
||||||
22
node_modules/@rollup/rollup-darwin-arm64/package.json
generated
vendored
22
node_modules/@rollup/rollup-darwin-arm64/package.json
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@rollup/rollup-darwin-arm64",
|
|
||||||
"version": "4.53.5",
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"rollup.darwin-arm64.node"
|
|
||||||
],
|
|
||||||
"description": "Native bindings for Rollup",
|
|
||||||
"author": "Lukas Taegert-Atkinson",
|
|
||||||
"homepage": "https://rollupjs.org/",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/rollup/rollup.git"
|
|
||||||
},
|
|
||||||
"main": "./rollup.darwin-arm64.node"
|
|
||||||
}
|
|
||||||
BIN
node_modules/@rollup/rollup-darwin-arm64/rollup.darwin-arm64.node
generated
vendored
BIN
node_modules/@rollup/rollup-darwin-arm64/rollup.darwin-arm64.node
generated
vendored
Binary file not shown.
BIN
node_modules/esbuild/bin/esbuild
generated
vendored
BIN
node_modules/esbuild/bin/esbuild
generated
vendored
Binary file not shown.
22
node_modules/fsevents/LICENSE
generated
vendored
22
node_modules/fsevents/LICENSE
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
MIT License
|
|
||||||
-----------
|
|
||||||
|
|
||||||
Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
89
node_modules/fsevents/README.md
generated
vendored
89
node_modules/fsevents/README.md
generated
vendored
@@ -1,89 +0,0 @@
|
|||||||
# fsevents
|
|
||||||
|
|
||||||
Native access to MacOS FSEvents in [Node.js](https://nodejs.org/)
|
|
||||||
|
|
||||||
The FSEvents API in MacOS allows applications to register for notifications of
|
|
||||||
changes to a given directory tree. It is a very fast and lightweight alternative
|
|
||||||
to kqueue.
|
|
||||||
|
|
||||||
This is a low-level library. For a cross-platform file watching module that
|
|
||||||
uses fsevents, check out [Chokidar](https://github.com/paulmillr/chokidar).
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install fsevents
|
|
||||||
```
|
|
||||||
|
|
||||||
Supports only **Node.js v8.16 and higher**.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const fsevents = require('fsevents');
|
|
||||||
|
|
||||||
// To start observation
|
|
||||||
const stop = fsevents.watch(__dirname, (path, flags, id) => {
|
|
||||||
const info = fsevents.getInfo(path, flags);
|
|
||||||
});
|
|
||||||
|
|
||||||
// To end observation
|
|
||||||
stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Important note:** The API behaviour is slightly different from typical JS APIs. The `stop` function **must** be
|
|
||||||
> retrieved and stored somewhere, even if you don't plan to stop the watcher. If you forget it, the garbage collector
|
|
||||||
> will eventually kick in, the watcher will be unregistered, and your callbacks won't be called anymore.
|
|
||||||
|
|
||||||
The callback passed as the second parameter to `.watch` get's called whenever the operating system detects a
|
|
||||||
a change in the file system. It takes three arguments:
|
|
||||||
|
|
||||||
###### `fsevents.watch(dirname: string, (path: string, flags: number, id: string) => void): () => Promise<undefined>`
|
|
||||||
|
|
||||||
* `path: string` - the item in the filesystem that have been changed
|
|
||||||
* `flags: number` - a numeric value describing what the change was
|
|
||||||
* `id: string` - an unique-id identifying this specific event
|
|
||||||
|
|
||||||
Returns closer callback which when called returns a Promise resolving when the watcher process has been shut down.
|
|
||||||
|
|
||||||
###### `fsevents.getInfo(path: string, flags: number, id: string): FsEventInfo`
|
|
||||||
|
|
||||||
The `getInfo` function takes the `path`, `flags` and `id` arguments and converts those parameters into a structure
|
|
||||||
that is easier to digest to determine what the change was.
|
|
||||||
|
|
||||||
The `FsEventsInfo` has the following shape:
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* @typedef {'created'|'modified'|'deleted'|'moved'|'root-changed'|'cloned'|'unknown'} FsEventsEvent
|
|
||||||
* @typedef {'file'|'directory'|'symlink'} FsEventsType
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
"event": "created", // {FsEventsEvent}
|
|
||||||
"path": "file.txt",
|
|
||||||
"type": "file", // {FsEventsType}
|
|
||||||
"changes": {
|
|
||||||
"inode": true, // Had iNode Meta-Information changed
|
|
||||||
"finder": false, // Had Finder Meta-Data changed
|
|
||||||
"access": false, // Had access permissions changed
|
|
||||||
"xattrs": false // Had xAttributes changed
|
|
||||||
},
|
|
||||||
"flags": 0x100000000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
- v2.3 supports Apple Silicon ARM CPUs
|
|
||||||
- v2 supports node 8.16+ and reduces package size massively
|
|
||||||
- v1.2.8 supports node 6+
|
|
||||||
- v1.2.7 supports node 4+
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- I'm getting `EBADPLATFORM` `Unsupported platform for fsevents` error.
|
|
||||||
- It's fine, nothing is broken. fsevents is macos-only. Other platforms are skipped. If you want to hide this warning, report a bug to NPM bugtracker asking them to hide ebadplatform warnings by default.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The MIT License Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller — see LICENSE file.
|
|
||||||
|
|
||||||
Visit our [GitHub page](https://github.com/fsevents/fsevents) and [NPM Page](https://npmjs.org/package/fsevents)
|
|
||||||
46
node_modules/fsevents/fsevents.d.ts
generated
vendored
46
node_modules/fsevents/fsevents.d.ts
generated
vendored
@@ -1,46 +0,0 @@
|
|||||||
declare type Event = "created" | "cloned" | "modified" | "deleted" | "moved" | "root-changed" | "unknown";
|
|
||||||
declare type Type = "file" | "directory" | "symlink";
|
|
||||||
declare type FileChanges = {
|
|
||||||
inode: boolean;
|
|
||||||
finder: boolean;
|
|
||||||
access: boolean;
|
|
||||||
xattrs: boolean;
|
|
||||||
};
|
|
||||||
declare type Info = {
|
|
||||||
event: Event;
|
|
||||||
path: string;
|
|
||||||
type: Type;
|
|
||||||
changes: FileChanges;
|
|
||||||
flags: number;
|
|
||||||
};
|
|
||||||
declare type WatchHandler = (path: string, flags: number, id: string) => void;
|
|
||||||
export declare function watch(path: string, handler: WatchHandler): () => Promise<void>;
|
|
||||||
export declare function watch(path: string, since: number, handler: WatchHandler): () => Promise<void>;
|
|
||||||
export declare function getInfo(path: string, flags: number): Info;
|
|
||||||
export declare const constants: {
|
|
||||||
None: 0x00000000;
|
|
||||||
MustScanSubDirs: 0x00000001;
|
|
||||||
UserDropped: 0x00000002;
|
|
||||||
KernelDropped: 0x00000004;
|
|
||||||
EventIdsWrapped: 0x00000008;
|
|
||||||
HistoryDone: 0x00000010;
|
|
||||||
RootChanged: 0x00000020;
|
|
||||||
Mount: 0x00000040;
|
|
||||||
Unmount: 0x00000080;
|
|
||||||
ItemCreated: 0x00000100;
|
|
||||||
ItemRemoved: 0x00000200;
|
|
||||||
ItemInodeMetaMod: 0x00000400;
|
|
||||||
ItemRenamed: 0x00000800;
|
|
||||||
ItemModified: 0x00001000;
|
|
||||||
ItemFinderInfoMod: 0x00002000;
|
|
||||||
ItemChangeOwner: 0x00004000;
|
|
||||||
ItemXattrMod: 0x00008000;
|
|
||||||
ItemIsFile: 0x00010000;
|
|
||||||
ItemIsDir: 0x00020000;
|
|
||||||
ItemIsSymlink: 0x00040000;
|
|
||||||
ItemIsHardlink: 0x00100000;
|
|
||||||
ItemIsLastHardlink: 0x00200000;
|
|
||||||
OwnEvent: 0x00080000;
|
|
||||||
ItemCloned: 0x00400000;
|
|
||||||
};
|
|
||||||
export {};
|
|
||||||
83
node_modules/fsevents/fsevents.js
generated
vendored
83
node_modules/fsevents/fsevents.js
generated
vendored
@@ -1,83 +0,0 @@
|
|||||||
/*
|
|
||||||
** © 2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
|
|
||||||
** Licensed under MIT License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* jshint node:true */
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
throw new Error(`Module 'fsevents' is not compatible with platform '${process.platform}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Native = require("./fsevents.node");
|
|
||||||
const events = Native.constants;
|
|
||||||
|
|
||||||
function watch(path, since, handler) {
|
|
||||||
if (typeof path !== "string") {
|
|
||||||
throw new TypeError(`fsevents argument 1 must be a string and not a ${typeof path}`);
|
|
||||||
}
|
|
||||||
if ("function" === typeof since && "undefined" === typeof handler) {
|
|
||||||
handler = since;
|
|
||||||
since = Native.flags.SinceNow;
|
|
||||||
}
|
|
||||||
if (typeof since !== "number") {
|
|
||||||
throw new TypeError(`fsevents argument 2 must be a number and not a ${typeof since}`);
|
|
||||||
}
|
|
||||||
if (typeof handler !== "function") {
|
|
||||||
throw new TypeError(`fsevents argument 3 must be a function and not a ${typeof handler}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let instance = Native.start(Native.global, path, since, handler);
|
|
||||||
if (!instance) throw new Error(`could not watch: ${path}`);
|
|
||||||
return () => {
|
|
||||||
const result = instance ? Promise.resolve(instance).then(Native.stop) : Promise.resolve(undefined);
|
|
||||||
instance = undefined;
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInfo(path, flags) {
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
flags,
|
|
||||||
event: getEventType(flags),
|
|
||||||
type: getFileType(flags),
|
|
||||||
changes: getFileChanges(flags),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileType(flags) {
|
|
||||||
if (events.ItemIsFile & flags) return "file";
|
|
||||||
if (events.ItemIsDir & flags) return "directory";
|
|
||||||
if (events.MustScanSubDirs & flags) return "directory";
|
|
||||||
if (events.ItemIsSymlink & flags) return "symlink";
|
|
||||||
}
|
|
||||||
function anyIsTrue(obj) {
|
|
||||||
for (let key in obj) {
|
|
||||||
if (obj[key]) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function getEventType(flags) {
|
|
||||||
if (events.ItemRemoved & flags) return "deleted";
|
|
||||||
if (events.ItemRenamed & flags) return "moved";
|
|
||||||
if (events.ItemCreated & flags) return "created";
|
|
||||||
if (events.ItemModified & flags) return "modified";
|
|
||||||
if (events.RootChanged & flags) return "root-changed";
|
|
||||||
if (events.ItemCloned & flags) return "cloned";
|
|
||||||
if (anyIsTrue(flags)) return "modified";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
function getFileChanges(flags) {
|
|
||||||
return {
|
|
||||||
inode: !!(events.ItemInodeMetaMod & flags),
|
|
||||||
finder: !!(events.ItemFinderInfoMod & flags),
|
|
||||||
access: !!(events.ItemChangeOwner & flags),
|
|
||||||
xattrs: !!(events.ItemXattrMod & flags),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.watch = watch;
|
|
||||||
exports.getInfo = getInfo;
|
|
||||||
exports.constants = events;
|
|
||||||
BIN
node_modules/fsevents/fsevents.node
generated
vendored
BIN
node_modules/fsevents/fsevents.node
generated
vendored
Binary file not shown.
62
node_modules/fsevents/package.json
generated
vendored
62
node_modules/fsevents/package.json
generated
vendored
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "fsevents",
|
|
||||||
"version": "2.3.3",
|
|
||||||
"description": "Native Access to MacOS FSEvents",
|
|
||||||
"main": "fsevents.js",
|
|
||||||
"types": "fsevents.d.ts",
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"fsevents.d.ts",
|
|
||||||
"fsevents.js",
|
|
||||||
"fsevents.node"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"clean": "node-gyp clean && rm -f fsevents.node",
|
|
||||||
"build": "node-gyp clean && rm -f fsevents.node && node-gyp rebuild && node-gyp clean",
|
|
||||||
"test": "/bin/bash ./test.sh 2>/dev/null",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/fsevents/fsevents.git"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"fsevents",
|
|
||||||
"mac"
|
|
||||||
],
|
|
||||||
"contributors": [
|
|
||||||
{
|
|
||||||
"name": "Philipp Dunkel",
|
|
||||||
"email": "pip@pipobscure.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ben Noordhuis",
|
|
||||||
"email": "info@bnoordhuis.nl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Elan Shankar",
|
|
||||||
"email": "elan.shanker@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Miroslav Bajtoš",
|
|
||||||
"email": "mbajtoss@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Paul Miller",
|
|
||||||
"url": "https://paulmillr.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/fsevents/fsevents/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/fsevents/fsevents",
|
|
||||||
"devDependencies": {
|
|
||||||
"node-gyp": "^9.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1189
package-lock.json
generated
1189
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
97
src/App.jsx
97
src/App.jsx
@@ -1,8 +1,7 @@
|
|||||||
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'
|
||||||
@@ -12,34 +11,44 @@ import ProjectsPage from './pages/ProjectsPage'
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<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>} />
|
||||||
@@ -53,73 +62,21 @@ function AppRoutes() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return <AppRoutes />
|
||||||
<>
|
}
|
||||||
{/* PixelBlast Background für Login-Seite */}
|
|
||||||
<div style={{
|
return (
|
||||||
position: 'fixed',
|
<div className="app-shell">
|
||||||
top: 0,
|
<Sidebar />
|
||||||
left: 0,
|
<div className="app-main">
|
||||||
width: '100vw',
|
<div className="app-scroll">
|
||||||
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 />
|
<AppRoutes />
|
||||||
</div>
|
</div>
|
||||||
</>
|
<Footer />
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* PixelBlast Background für Haupt-App */}
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
zIndex: 0,
|
|
||||||
background: '#1a202c'
|
|
||||||
}}>
|
|
||||||
<PixelBlast
|
|
||||||
color="#10b981"
|
|
||||||
variant="circle"
|
|
||||||
pixelSize={4}
|
|
||||||
patternScale={2}
|
|
||||||
patternDensity={0.8}
|
|
||||||
enableRipples={true}
|
|
||||||
rippleIntensityScale={1.5}
|
|
||||||
speed={0.3}
|
|
||||||
edgeFade={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
</div>
|
||||||
<Navbar />
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
src/components/AssignedProjectCard.jsx
Normal file
97
src/components/AssignedProjectCard.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
|
||||||
|
import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
|
||||||
|
import ProjectReadme from './ProjectReadme'
|
||||||
|
import GiteaLinkButton from './GiteaLinkButton'
|
||||||
|
import PreviewLinkButton from './PreviewLinkButton'
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
ready: '#10b981',
|
||||||
|
live: '#10b981',
|
||||||
|
online: '#10b981',
|
||||||
|
provisioning: '#f59e0b',
|
||||||
|
pending: '#f59e0b',
|
||||||
|
building: '#3b82f6',
|
||||||
|
error: '#ef4444',
|
||||||
|
failed: '#ef4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ label, value }) {
|
||||||
|
if (!value) return null
|
||||||
|
const color = STATUS_COLORS[String(value).toLowerCase()] || '#a0aec0'
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e0', marginRight: 12 }}>
|
||||||
|
{label}:
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||||
|
const lastPush = project.lastPushAt || project.giteaSyncedAt
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<strong>{project.projectName}</strong>
|
||||||
|
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
||||||
|
{project.repoFullName && (
|
||||||
|
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.liveDomain && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
<FaGlobe style={{ marginRight: 4, color: '#34d399' }} />
|
||||||
|
<a href={`https://${project.liveDomain}`} target="_blank" rel="noopener noreferrer" style={{ color: '#34d399', textDecoration: 'none' }}>
|
||||||
|
{project.liveDomain}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||||
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
|
{showActions && onUnassign && (
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => onUnassign(project.$id)}><FaTrash /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status- und Provisioning-Zeile */}
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<StatusBadge label="Status" value={project.status} />
|
||||||
|
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||||
|
{project.hasPreview && <StatusBadge label="Preview" value="aktiv" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Letzter Commit */}
|
||||||
|
{(project.lastCommitMessage || project.lastCommitSha) && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#94a3b8', background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: '8px 10px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<FaCodeCommit style={{ color: '#3b82f6' }} />
|
||||||
|
{project.lastCommitSha && <code style={{ color: '#cbd5e0' }}>{String(project.lastCommitSha).slice(0, 7)}</code>}
|
||||||
|
{project.lastCommitBranch || project.lastPushBranch ? (
|
||||||
|
<span className="text-grey">({project.lastPushBranch || project.lastCommitBranch})</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{project.lastCommitMessage && (
|
||||||
|
<div style={{ marginTop: 4, color: '#cbd5e0', whiteSpace: 'pre-wrap' }}>{project.lastCommitMessage}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 4, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
{project.lastCommitAuthor && <span>von {project.lastCommitAuthor}</span>}
|
||||||
|
{lastPush && (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<FaClock /> {new Date(lastPush).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/components/CopyableCredential.jsx
Normal file
83
src/components/CopyableCredential.jsx
Normal 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 || ''
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { FaTimes } from 'react-icons/fa'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
|
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
|
||||||
@@ -29,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', {
|
||||||
@@ -54,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,
|
||||||
@@ -157,77 +167,26 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
if (!isOpen || !workorder) return null
|
if (!isOpen || !workorder) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" style={{
|
<div className="overlay">
|
||||||
width: '100%',
|
<span className="overlay-close" onClick={onClose}>
|
||||||
background: 'rgba(0,0,0,0.95)'
|
<FaTimes />
|
||||||
}}>
|
</span>
|
||||||
<a href="#" className="closebtn" onClick={(e) => { e.preventDefault(); onClose(); }} style={{
|
<div className="overlay-content">
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
<h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2>
|
||||||
borderRadius: '50%',
|
|
||||||
width: '60px',
|
{error && (
|
||||||
height: '60px',
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
display: 'flex',
|
{error}
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '2rem',
|
|
||||||
transition: 'transform 0.2s ease'
|
|
||||||
}} onMouseEnter={(e) => e.currentTarget.style.transform = 'rotate(90deg)'} onMouseLeave={(e) => e.currentTarget.style.transform = 'rotate(0deg)'}>×</a>
|
|
||||||
|
|
||||||
<div className="overlay-content text-white text-left">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="container">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
<div className="col-10">
|
|
||||||
<div className="mb-4 p-4 rounded-3" style={{
|
|
||||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
|
||||||
boxShadow: '0 8px 32px rgba(45, 55, 72, 0.3)'
|
|
||||||
}}>
|
|
||||||
<h2 className="mb-0 d-flex align-items-center">
|
|
||||||
<span className="me-3" style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.4)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '10px 15px'
|
|
||||||
}}>📝</span>
|
|
||||||
Create New Worksheet
|
|
||||||
<span className="ms-3 badge" style={{
|
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
||||||
fontSize: '1rem'
|
|
||||||
}}>WOID {workorder.woid}</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
|
||||||
<div className="container">
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-1"> </div>
|
<div className="col col-6">
|
||||||
<div className="col-10">
|
<div className="form-group">
|
||||||
<div className="alert p-4 rounded-3 border-0" style={{
|
<label className="form-label">Service Type</label>
|
||||||
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
<select
|
||||||
color: 'white',
|
className="form-control"
|
||||||
boxShadow: '0 4px 16px rgba(239, 68, 68, 0.3)'
|
|
||||||
}} role="alert">
|
|
||||||
<strong>⚠️ {error}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="container">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
|
|
||||||
{/* Linke Spalte */}
|
|
||||||
<div className="col-5">
|
|
||||||
<span className="text-left">Service Type</span><br />
|
|
||||||
<select
|
|
||||||
className="form-select bg-dark text-white"
|
|
||||||
value={formData.serviceType}
|
value={formData.serviceType}
|
||||||
onChange={(e) => handleChange('serviceType', e.target.value)}
|
onChange={(e) => handleChange('serviceType', e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -236,55 +195,59 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<option key={type} value={type}>{type}</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<span className="text-left">New Status</span><br />
|
<div className="form-group">
|
||||||
<select
|
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
||||||
className="form-select bg-dark text-white"
|
<select
|
||||||
|
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>
|
||||||
<br /><br />
|
{isAcquisition && (
|
||||||
|
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||||
|
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="text-left">New Response Level</span><br />
|
<div className="form-group">
|
||||||
<select
|
<label className="form-label">New Response Level</label>
|
||||||
className="form-select bg-dark text-white"
|
<select
|
||||||
|
className="form-control"
|
||||||
value={formData.newResponseLevel}
|
value={formData.newResponseLevel}
|
||||||
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
|
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Select</option>
|
<option value="">Select Response Level</option>
|
||||||
{RESPONSE_LEVELS.map(level => (
|
{RESPONSE_LEVELS.map(level => (
|
||||||
<option key={level} value={level}>{level}</option>
|
<option key={level} value={level}>{level}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<div className="form-check">
|
<div className="form-group">
|
||||||
<input
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
type="checkbox"
|
<input
|
||||||
className="form-check-input"
|
type="checkbox"
|
||||||
id="isComment"
|
|
||||||
checked={formData.isComment}
|
checked={formData.isComment}
|
||||||
onChange={(e) => handleChange('isComment', e.target.checked)}
|
onChange={(e) => handleChange('isComment', e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<label className="form-check-label" htmlFor="isComment">
|
Nur Kommentar (keine Arbeitszeit)
|
||||||
Nur Kommentar (keine Arbeitszeit)
|
</label>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Rechte Spalte */}
|
<div className="col col-6">
|
||||||
<div className="col-5">
|
<div className="form-group">
|
||||||
<span className="text-left">Total Time (Minuten)</span><br />
|
<label className="form-label">Total Time (Minuten)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control bg-dark text-white"
|
className="form-control"
|
||||||
min="0"
|
min="0"
|
||||||
step="15"
|
step="15"
|
||||||
value={formData.totalTime}
|
value={formData.totalTime}
|
||||||
@@ -292,145 +255,83 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
disabled={formData.isComment}
|
disabled={formData.isComment}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<small className="text-muted">
|
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||||
{autoCalculate && formData.startTime && formData.endTime
|
{autoCalculate && formData.startTime && formData.endTime
|
||||||
? '✓ Automatisch berechnet'
|
? '✓ Automatisch berechnet'
|
||||||
: 'Manuell eingeben'}
|
: 'Manuell eingeben'}
|
||||||
</small>
|
</small>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<span className="text-left">Start Date (dd.mm.yyyy)</span><br />
|
<div className="form-group">
|
||||||
<input
|
<label className="form-label">Start Date</label>
|
||||||
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white"
|
className="form-control"
|
||||||
|
placeholder="dd.mm.yyyy"
|
||||||
value={formData.startDate}
|
value={formData.startDate}
|
||||||
onChange={(e) => handleChange('startDate', e.target.value)}
|
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||||
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<span className="text-left">End Date (dd.mm.yyyy)</span><br />
|
<div className="form-group">
|
||||||
<input
|
<label className="form-label">End Date</label>
|
||||||
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white"
|
className="form-control"
|
||||||
|
placeholder="dd.mm.yyyy"
|
||||||
value={formData.endDate}
|
value={formData.endDate}
|
||||||
onChange={(e) => handleChange('endDate', e.target.value)}
|
onChange={(e) => handleChange('endDate', e.target.value)}
|
||||||
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<span className="text-left">Start Time (hhmm)</span><br />
|
<div className="form-group">
|
||||||
<input
|
<label className="form-label">Start Time</label>
|
||||||
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white"
|
className="form-control"
|
||||||
|
placeholder="hhmm"
|
||||||
value={formData.startTime}
|
value={formData.startTime}
|
||||||
onChange={(e) => handleChange('startTime', e.target.value)}
|
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||||
pattern="[0-2][0-9][0-5][0-9]"
|
|
||||||
placeholder="1000"
|
|
||||||
maxLength="4"
|
maxLength="4"
|
||||||
/>
|
/>
|
||||||
<br /><br />
|
</div>
|
||||||
|
|
||||||
<span className="text-left">End Time (hhmm)</span><br />
|
<div className="form-group">
|
||||||
<input
|
<label className="form-label">End Time</label>
|
||||||
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control bg-dark text-white"
|
className="form-control"
|
||||||
|
placeholder="hhmm"
|
||||||
value={formData.endTime}
|
value={formData.endTime}
|
||||||
onChange={(e) => handleChange('endTime', e.target.value)}
|
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||||
pattern="[0-2][0-9][0-5][0-9]"
|
|
||||||
placeholder="1030"
|
|
||||||
maxLength="4"
|
maxLength="4"
|
||||||
/>
|
/>
|
||||||
<br /><br />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container">
|
<div className="form-group">
|
||||||
<div className="row">
|
<label className="form-label">Action Details</label>
|
||||||
<div className="col-1"> </div>
|
<textarea
|
||||||
<div className="col-10">
|
className="form-control"
|
||||||
<span className="text-left">Action Details</span><br />
|
rows={5}
|
||||||
<textarea
|
placeholder="Beschreibe die durchgeführten Arbeiten..."
|
||||||
className="form-control bg-dark text-white"
|
value={formData.details}
|
||||||
rows="10"
|
onChange={(e) => handleChange('details', e.target.value)}
|
||||||
value={formData.details}
|
required
|
||||||
onChange={(e) => handleChange('details', e.target.value)}
|
/>
|
||||||
placeholder="Beschreibe die durchgeführten Arbeiten..."
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container">
|
<div className="text-center mt-2">
|
||||||
<div className="row">
|
<button
|
||||||
<div className="col-1"> </div>
|
type="submit"
|
||||||
<div className="col-10 text-center">
|
className="btn btn-dark"
|
||||||
<p> </p>
|
disabled={loading}
|
||||||
<button
|
>
|
||||||
type="submit"
|
{loading ? 'Creating...' : 'CREATE NOW'}
|
||||||
className="btn btn-lg px-5 py-3 border-0"
|
</button>
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
boxShadow: '0 8px 32px rgba(16, 185, 129, 0.4)',
|
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(16, 185, 129, 0.5)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 8px 32px rgba(16, 185, 129, 0.4)'
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? '⏳ Erstelle...' : '✨ CREATE NOW'}
|
|
||||||
</button>
|
|
||||||
<p> </p>
|
|
||||||
</div>
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="container">
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
<div className="col-10">
|
|
||||||
<div className="p-4 rounded-3 border-0" style={{
|
|
||||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
|
||||||
color: 'white',
|
|
||||||
boxShadow: '0 4px 16px rgba(74, 85, 104, 0.3)'
|
|
||||||
}} role="alert">
|
|
||||||
<strong className="d-block mb-2">📋 Current Work Order</strong>
|
|
||||||
<div className="d-flex flex-wrap gap-3">
|
|
||||||
<span className="badge px-3 py-2" style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.4)',
|
|
||||||
fontSize: '0.9rem'
|
|
||||||
}}>WOID: {workorder.woid}</span>
|
|
||||||
<span className="badge px-3 py-2" style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.4)',
|
|
||||||
fontSize: '0.9rem'
|
|
||||||
}}>Status: {workorder.status}</span>
|
|
||||||
<span className="badge px-3 py-2" style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.4)',
|
|
||||||
fontSize: '0.9rem'
|
|
||||||
}}>Topic: {workorder.topic}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-1"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
158
src/components/DocumentsPanel.jsx
Normal file
158
src/components/DocumentsPanel.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FaFileLines, FaDownload, FaEye, FaSpinner, FaRotate, FaUpload, FaMagnifyingGlass } from 'react-icons/fa6'
|
||||||
|
import { integrationsApi } from '../lib/integrationsApi'
|
||||||
|
import FileUploadModal from './FileUploadModal'
|
||||||
|
|
||||||
|
const card = {
|
||||||
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsPanel({ ticket, initialScope = 'ticket' }) {
|
||||||
|
const [docs, setDocs] = useState([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [scope, setScope] = useState(initialScope) // 'ticket' | 'customer'
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
|
||||||
|
const correspondent = ticket.customerName || ''
|
||||||
|
const woid = ticket.woid || ''
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = { page_size: 30 }
|
||||||
|
if (query) params.query = query
|
||||||
|
if (scope === 'customer' && correspondent) params.correspondent = correspondent
|
||||||
|
if (scope === 'ticket' && woid) params.tag = `WOID-${woid}`
|
||||||
|
const data = await integrationsApi.listDocuments(params)
|
||||||
|
setDocs(data.documents || [])
|
||||||
|
setCount(data.count || 0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setDocs([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, scope, correspondent, woid])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const open = async (id, kind) => {
|
||||||
|
setBusyId(id + kind)
|
||||||
|
try {
|
||||||
|
await integrationsApi.openDocument(id, kind)
|
||||||
|
} catch (err) {
|
||||||
|
alert('Dokument konnte nicht geoeffnet werden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||||
|
Dokumente (Paperless)
|
||||||
|
</h5>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} onClick={() => setShowUpload(true)}>
|
||||||
|
<FaUpload /> Hochladen
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={load} title="Aktualisieren"
|
||||||
|
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||||
|
<FaRotate />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
className={`btn ${scope === 'ticket' ? 'btn-green' : ''}`}
|
||||||
|
style={{ margin: 0, background: scope === 'ticket' ? undefined : '#374151', color: 'white' }}
|
||||||
|
onClick={() => setScope('ticket')}
|
||||||
|
>
|
||||||
|
Dieses Ticket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${scope === 'customer' ? 'btn-green' : ''}`}
|
||||||
|
style={{ margin: 0, background: scope === 'customer' ? undefined : '#374151', color: 'white' }}
|
||||||
|
onClick={() => setScope('customer')}
|
||||||
|
disabled={!correspondent}
|
||||||
|
>
|
||||||
|
Ganzer Kunde
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flex: 1, minWidth: '180px' }}>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
placeholder="Volltextsuche..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && load()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} onClick={load}>
|
||||||
|
<FaMagnifyingGlass />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Dokumente werden geladen...</p>
|
||||||
|
) : docs.length === 0 ? (
|
||||||
|
<p style={{ color: '#a0aec0', fontSize: '13px' }}>
|
||||||
|
Keine Dokumente gefunden{scope === 'ticket' ? ' fuer dieses Ticket' : correspondent ? ` fuer ${correspondent}` : ''}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#718096', fontSize: '12px', marginTop: 0 }}>{count} Dokument(e)</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{docs.map((d) => (
|
||||||
|
<div key={d.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: 'rgba(0,0,0,0.15)', borderRadius: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||||
|
<FaFileLines style={{ color: '#10b981', flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ color: 'var(--dark-text)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
|
||||||
|
<div style={{ color: '#718096', fontSize: '12px' }}>{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||||
|
<button className="btn" title="Vorschau" disabled={busyId === d.id + 'preview'}
|
||||||
|
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => open(d.id, 'preview')}>
|
||||||
|
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
|
||||||
|
</button>
|
||||||
|
<button className="btn" title="Download" disabled={busyId === d.id + 'download'}
|
||||||
|
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => open(d.id, 'download')}>
|
||||||
|
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FileUploadModal
|
||||||
|
isOpen={showUpload}
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
workorderId={woid}
|
||||||
|
ticket={ticket}
|
||||||
|
onUploadComplete={() => setTimeout(load, 1500)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/EditWorksheetTimeModal.jsx
Normal file
142
src/components/EditWorksheetTimeModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,14 +35,39 @@ 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(
|
||||||
BUCKET_ID,
|
BUCKET_ID,
|
||||||
ID.unique(),
|
ID.unique(),
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
30
src/components/GiteaLinkButton.jsx
Normal file
30
src/components/GiteaLinkButton.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SiGitea } from 'react-icons/si'
|
||||||
|
|
||||||
|
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
|
||||||
|
if (!href) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.35)',
|
||||||
|
color: '#818cf8',
|
||||||
|
textDecoration: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SiGitea size={size} />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
343
src/components/InvoicePanel.jsx
Normal file
343
src/components/InvoicePanel.jsx
Normal 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 & 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 "versendet" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
src/components/LetterGlitch.jsx
Normal file
245
src/components/LetterGlitch.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const LetterGlitch = ({
|
||||||
|
glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],
|
||||||
|
className = '',
|
||||||
|
glitchSpeed = 50,
|
||||||
|
centerVignette = false,
|
||||||
|
outerVignette = true,
|
||||||
|
smooth = true,
|
||||||
|
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
const letters = useRef([]);
|
||||||
|
const grid = useRef({ columns: 0, rows: 0 });
|
||||||
|
const context = useRef(null);
|
||||||
|
const lastGlitchTime = useRef(Date.now());
|
||||||
|
|
||||||
|
const lettersAndSymbols = Array.from(characters);
|
||||||
|
|
||||||
|
const fontSize = 16;
|
||||||
|
const charWidth = 10;
|
||||||
|
const charHeight = 20;
|
||||||
|
|
||||||
|
const getRandomChar = () => {
|
||||||
|
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRandomColor = () => {
|
||||||
|
return glitchColors[Math.floor(Math.random() * glitchColors.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToRgb = hex => {
|
||||||
|
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||||
|
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
|
||||||
|
return r + r + g + g + b + b;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result
|
||||||
|
? {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16)
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const interpolateColor = (start, end, factor) => {
|
||||||
|
const result = {
|
||||||
|
r: Math.round(start.r + (end.r - start.r) * factor),
|
||||||
|
g: Math.round(start.g + (end.g - start.g) * factor),
|
||||||
|
b: Math.round(start.b + (end.b - start.b) * factor)
|
||||||
|
};
|
||||||
|
return `rgb(${result.r}, ${result.g}, ${result.b})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateGrid = (width, height) => {
|
||||||
|
const columns = Math.ceil(width / charWidth);
|
||||||
|
const rows = Math.ceil(height / charHeight);
|
||||||
|
return { columns, rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeLetters = (columns, rows) => {
|
||||||
|
grid.current = { columns, rows };
|
||||||
|
const totalLetters = columns * rows;
|
||||||
|
letters.current = Array.from({ length: totalLetters }, () => ({
|
||||||
|
char: getRandomChar(),
|
||||||
|
color: getRandomColor(),
|
||||||
|
targetColor: getRandomColor(),
|
||||||
|
colorProgress: 1
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeCanvas = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
if (context.current) {
|
||||||
|
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columns, rows } = calculateGrid(rect.width, rect.height);
|
||||||
|
initializeLetters(columns, rows);
|
||||||
|
|
||||||
|
drawLetters();
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawLetters = () => {
|
||||||
|
if (!context.current || letters.current.length === 0) return;
|
||||||
|
const ctx = context.current;
|
||||||
|
const { width, height } = canvasRef.current.getBoundingClientRect();
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.font = `${fontSize}px monospace`;
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
letters.current.forEach((letter, index) => {
|
||||||
|
const x = (index % grid.current.columns) * charWidth;
|
||||||
|
const y = Math.floor(index / grid.current.columns) * charHeight;
|
||||||
|
ctx.fillStyle = letter.color;
|
||||||
|
ctx.fillText(letter.char, x, y);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLetters = () => {
|
||||||
|
if (!letters.current || letters.current.length === 0) return;
|
||||||
|
|
||||||
|
const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));
|
||||||
|
|
||||||
|
for (let i = 0; i < updateCount; i++) {
|
||||||
|
const index = Math.floor(Math.random() * letters.current.length);
|
||||||
|
if (!letters.current[index]) continue;
|
||||||
|
|
||||||
|
letters.current[index].char = getRandomChar();
|
||||||
|
letters.current[index].targetColor = getRandomColor();
|
||||||
|
|
||||||
|
if (!smooth) {
|
||||||
|
letters.current[index].color = letters.current[index].targetColor;
|
||||||
|
letters.current[index].colorProgress = 1;
|
||||||
|
} else {
|
||||||
|
letters.current[index].colorProgress = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmoothTransitions = () => {
|
||||||
|
let needsRedraw = false;
|
||||||
|
letters.current.forEach(letter => {
|
||||||
|
if (letter.colorProgress < 1) {
|
||||||
|
letter.colorProgress += 0.05;
|
||||||
|
if (letter.colorProgress > 1) letter.colorProgress = 1;
|
||||||
|
|
||||||
|
const startRgb = hexToRgb(letter.color);
|
||||||
|
const endRgb = hexToRgb(letter.targetColor);
|
||||||
|
if (startRgb && endRgb) {
|
||||||
|
letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
|
||||||
|
needsRedraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsRedraw) {
|
||||||
|
drawLetters();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastGlitchTime.current >= glitchSpeed) {
|
||||||
|
updateLetters();
|
||||||
|
drawLetters();
|
||||||
|
lastGlitchTime.current = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smooth) {
|
||||||
|
handleSmoothTransitions();
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
context.current = canvas.getContext('2d');
|
||||||
|
resizeCanvas();
|
||||||
|
animate();
|
||||||
|
|
||||||
|
let resizeTimeout;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
resizeCanvas();
|
||||||
|
animate();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [glitchSpeed, smooth]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
overflow: 'hidden'
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvasStyle = {
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const outerVignetteStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
background: 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const centerVignetteStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
background: 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={containerStyle} className={className}>
|
||||||
|
<canvas ref={canvasRef} style={canvasStyle} />
|
||||||
|
{outerVignette && <div style={outerVignetteStyle}></div>}
|
||||||
|
{centerVignette && <div style={centerVignetteStyle}></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LetterGlitch;
|
||||||
|
|
||||||
@@ -1,174 +1,126 @@
|
|||||||
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,
|
IconUsers,
|
||||||
IconDeviceDesktop,
|
IconTargetArrow,
|
||||||
IconChartBar,
|
IconReportMoney,
|
||||||
IconCalendar,
|
IconFiles,
|
||||||
IconFolders,
|
IconFolders,
|
||||||
IconFileText,
|
IconCalendar,
|
||||||
IconBook,
|
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: '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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/components/PreviewLinkButton.jsx
Normal file
30
src/components/PreviewLinkButton.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/components/ProjectReadme.jsx
Normal file
50
src/components/ProjectReadme.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/components/StatusHistoryModal.jsx
Normal file
143
src/components/StatusHistoryModal.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { FaTimes } from 'react-icons/fa'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
export default function StatusHistoryModal({ isOpen, onClose, worksheets, ticket }) {
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Extrahiere Status-Änderungen aus Worksheets
|
||||||
|
const statusHistory = worksheets
|
||||||
|
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
|
||||||
|
.map(ws => ({
|
||||||
|
date: ws.startDate,
|
||||||
|
time: ws.startTime,
|
||||||
|
from: ws.oldStatus,
|
||||||
|
to: ws.newStatus,
|
||||||
|
employee: ws.employeeName || ws.employeeShort || 'Unknown',
|
||||||
|
details: ws.details,
|
||||||
|
wsid: ws.wsid
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sortiere nach Datum und Zeit (älteste zuerst)
|
||||||
|
const dateA = `${a.date} ${a.time || '0000'}`
|
||||||
|
const dateB = `${b.date} ${b.time || '0000'}`
|
||||||
|
return dateA.localeCompare(dateB)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Füge aktuellen Status hinzu
|
||||||
|
const currentStatus = ticket?.status || 'Open'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay">
|
||||||
|
<span className="overlay-close" onClick={onClose}>
|
||||||
|
<FaTimes />
|
||||||
|
</span>
|
||||||
|
<div className="overlay-content">
|
||||||
|
<h2 className="mb-2">Status History - WOID {ticket?.woid || ticket?.$id}</h2>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(45, 55, 72, 0.95)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<strong style={{ color: 'var(--green-primary)' }}>Current Status:</strong>
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '12px',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'rgba(16, 185, 129, 0.2)',
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{currentStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusHistory.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(45, 55, 72, 0.95)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#a0aec0'
|
||||||
|
}}>
|
||||||
|
No status changes recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{statusHistory.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(45, 55, 72, 0.95)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
borderLeft: '4px solid var(--green-primary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '8px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#a0aec0', marginBottom: '4px' }}>
|
||||||
|
{entry.date} {entry.time ? `${entry.time.substring(0, 2)}:${entry.time.substring(2, 4)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'rgba(74, 85, 104, 0.5)',
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{entry.from}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--green-primary)' }}>→</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'rgba(16, 185, 129, 0.3)',
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{entry.to}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#a0aec0' }}>by</div>
|
||||||
|
<div style={{ fontWeight: 'bold', color: 'var(--dark-text)' }}>{entry.employee}</div>
|
||||||
|
{entry.wsid && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#718096', marginTop: '4px' }}>
|
||||||
|
WSID: {entry.wsid}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entry.details && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(31, 41, 55, 0.6)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#cbd5e0',
|
||||||
|
borderLeft: '3px solid rgba(16, 185, 129, 0.4)'
|
||||||
|
}}>
|
||||||
|
{entry.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
34
src/components/TicketAssignedProjectsPanel.jsx
Normal file
34
src/components/TicketAssignedProjectsPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
src/components/TicketCard.jsx
Normal file
166
src/components/TicketCard.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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 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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||||
|
<FaPen /> Projekte zuweisen / bearbeiten
|
||||||
|
</button>
|
||||||
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} />
|
||||||
|
<StatusHistoryModal isOpen={showHistoryModal} onClose={() => setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
|
||||||
|
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/TicketProjectsModal.jsx
Normal file
125
src/components/TicketProjectsModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus } 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,8 +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 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 = {
|
||||||
@@ -19,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',
|
||||||
@@ -42,37 +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)
|
||||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
const {
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
worksheets,
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
loading: worksheetsLoading,
|
|
||||||
|
const {
|
||||||
|
worksheets,
|
||||||
|
loading: worksheetsLoading,
|
||||||
createWorksheet,
|
createWorksheet,
|
||||||
getTotalTime
|
updateWorksheet,
|
||||||
|
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) => {
|
|
||||||
onUpdate(ticket.$id, { status: newStatus })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePriorityChange = (newPriority) => {
|
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
|
||||||
onUpdate(ticket.$id, { priority: newPriority })
|
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 handleEditorChange = (newEditor) => {
|
|
||||||
onUpdate(ticket.$id, { assignedTo: newEditor })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResponseChange = (newResponse) => {
|
|
||||||
onUpdate(ticket.$id, { responseLevel: newResponse })
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleLock = () => {
|
const toggleLock = () => {
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -86,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +102,8 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className="ticket-row">
|
<tr className={`ticket-row ${expanded ? 'ticket-expanded' : 'ticket-collapsed'}`}>
|
||||||
<td className="ticket-id" rowSpan={2}>
|
<td className={`ticket-id ${expanded ? 'ticket-id-expanded' : ''}`} rowSpan={2}>
|
||||||
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
|
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
|
||||||
<div className="ticket-time">{elapsed}</div>
|
<div className="ticket-time">{elapsed}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -134,25 +134,19 @@ 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'}`}
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
style={{ verticalAlign: 'middle' }}
|
style={{ verticalAlign: 'middle' }}
|
||||||
>
|
>
|
||||||
<ApprovalIcon size={24} />
|
<ApprovalIcon size={24} />
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="bg-dark-grey text-center text-white"
|
className="bg-dark-grey text-center text-white"
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
||||||
@@ -163,101 +157,149 @@ 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>
|
<td colSpan={10} className="worksheet-cell">
|
||||||
<td colSpan={10} className="p-2">
|
<div className="card" style={{
|
||||||
<div className="card">
|
borderRadius: '0 0 12px 12px',
|
||||||
<div className="card-header d-flex justify-content-between align-items-center" style={{
|
marginTop: 0,
|
||||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
color: 'white',
|
borderTop: 'none'
|
||||||
padding: '1rem 1.5rem'
|
}}>
|
||||||
}}>
|
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||||
<span className="fs-5 fw-bold">Details - WOID {ticket.woid || ticket.$id}</span>
|
|
||||||
<button
|
{/* Tab-Leiste */}
|
||||||
className="btn btn-sm px-4 py-2 border-0 fw-bold"
|
<div className="ticket-tabs">
|
||||||
style={{
|
{TABS.map((t) => (
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
<button
|
||||||
color: 'white',
|
key={t.id}
|
||||||
transition: 'all 0.2s ease'
|
type="button"
|
||||||
}}
|
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
|
||||||
onMouseEnter={(e) => {
|
onClick={() => setActiveTab(t.id)}
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
>
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
{t.label}
|
||||||
}}
|
</button>
|
||||||
onMouseLeave={(e) => {
|
))}
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
e.currentTarget.style.boxShadow = 'none'
|
|
||||||
}}
|
|
||||||
onClick={() => setShowCreateWorksheet(true)}
|
|
||||||
>
|
|
||||||
<FaPlus className="me-2" /> Add Worksheet
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
|
||||||
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
|
<div className="ticket-tab-content">
|
||||||
background: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
|
{activeTab === 'details' && (
|
||||||
border: '2px solid #10b981'
|
<div style={{
|
||||||
}}>
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
<h5 className="mb-3" style={{ color: '#1a202c', fontWeight: 'bold' }}>
|
borderRadius: '12px',
|
||||||
📋 Ticket-Beschreibung
|
padding: '20px',
|
||||||
</h5>
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
<p style={{
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
lineHeight: '1.8',
|
|
||||||
color: '#1f2937',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
margin: 0
|
|
||||||
}}>
|
}}>
|
||||||
{ticket.details || 'Keine Details vorhanden.'}
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||||
</p>
|
Ticket-Beschreibung
|
||||||
</div>
|
</h5>
|
||||||
|
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||||
<hr />
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
|
</p>
|
||||||
<h5 className="mt-4 mb-3">Worksheets (Arbeitsschritte)</h5>
|
</div>
|
||||||
|
)}
|
||||||
{/* Statistiken */}
|
|
||||||
{worksheets.length > 0 && (
|
{activeTab === 'worksheets' && (
|
||||||
<>
|
<div>
|
||||||
<WorksheetStats worksheets={worksheets} />
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||||
<hr />
|
<button
|
||||||
</>
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
|
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||||
|
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||||
|
}}
|
||||||
|
onClick={() => setShowCreateWorksheet(true)}
|
||||||
|
>
|
||||||
|
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
|
onClick={() => setShowProjectsModal(true)}
|
||||||
|
title="Projekte bearbeiten"
|
||||||
|
>
|
||||||
|
<FaPen size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
|
onClick={() => setShowHistoryModal(true)}
|
||||||
|
title="Status History"
|
||||||
|
>
|
||||||
|
<FaClockRotateLeft size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{worksheets.length > 0 && (
|
||||||
|
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||||
|
<WorksheetStats worksheets={worksheets} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||||
|
|
||||||
|
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||||
|
|
||||||
|
{activeTab === 'project' && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-green"
|
||||||
|
style={{ marginBottom: '12px' }}
|
||||||
|
onClick={() => setShowProjectsModal(true)}
|
||||||
|
>
|
||||||
|
<FaPen style={{ marginRight: 6 }} /> Projekte zuweisen / bearbeiten
|
||||||
|
</button>
|
||||||
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Worksheet-Liste */}
|
|
||||||
<WorksheetList
|
|
||||||
worksheets={worksheets}
|
|
||||||
totalTime={getTotalTime()}
|
|
||||||
loading={worksheetsLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateWorksheetModal
|
<CreateWorksheetModal
|
||||||
isOpen={showCreateWorksheet}
|
isOpen={showCreateWorksheet}
|
||||||
onClose={() => setShowCreateWorksheet(false)}
|
onClose={() => setShowCreateWorksheet(false)}
|
||||||
workorder={ticket}
|
workorder={ticket}
|
||||||
onCreate={handleCreateWorksheet}
|
onCreate={handleCreateWorksheet}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditWorksheetTimeModal
|
||||||
|
isOpen={Boolean(editTimeWorksheet)}
|
||||||
|
onClose={() => setEditTimeWorksheet(null)}
|
||||||
|
worksheet={editTimeWorksheet}
|
||||||
|
onSave={handleSaveWorksheetTime}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusHistoryModal
|
||||||
|
isOpen={showHistoryModal}
|
||||||
|
onClose={() => setShowHistoryModal(false)}
|
||||||
|
worksheets={worksheets}
|
||||||
|
ticket={ticket}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketProjectsModal
|
||||||
|
isOpen={showProjectsModal}
|
||||||
|
onClose={() => setShowProjectsModal(false)}
|
||||||
|
ticket={ticket}
|
||||||
|
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
<tr className="spacer">
|
<tr className="spacer">
|
||||||
<td colSpan={10} style={{ height: '8px', background: '#fff' }}></td>
|
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
109
src/components/WebpageProjectPanel.jsx
Normal file
109
src/components/WebpageProjectPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { FaClock, FaUser, FaExchangeAlt, FaComment } from 'react-icons/fa'
|
import { useState } from 'react'
|
||||||
|
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
|
||||||
|
|
||||||
export default function WorksheetList({ worksheets, totalTime, loading }) {
|
function needsTimeEntry(ws) {
|
||||||
|
return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime }) {
|
||||||
|
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4">
|
||||||
@@ -34,141 +40,216 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
return `${date} ${hours}:${mins}`
|
return `${date} ${hours}:${mins}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleWorksheet = (wsid) => {
|
||||||
|
setExpandedWorksheets(prev => ({
|
||||||
|
...prev,
|
||||||
|
[wsid]: !prev[wsid]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="worksheet-list">
|
<div className="worksheet-list">
|
||||||
{/* Gesamtzeit-Header */}
|
|
||||||
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
|
|
||||||
background: 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)',
|
|
||||||
border: 'none'
|
|
||||||
}}>
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<FaClock className="me-3" size={24} style={{ color: '#059669' }} />
|
|
||||||
<div>
|
|
||||||
<strong className="fs-5 d-block" style={{ color: '#064e3b' }}>Gesamtarbeitszeit</strong>
|
|
||||||
<span className="fs-3 fw-bold" style={{ color: '#059669' }}>{formatTime(totalTime)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-end">
|
|
||||||
<span className="badge px-3 py-2" style={{
|
|
||||||
background: 'rgba(5, 150, 105, 0.2)',
|
|
||||||
color: '#059669',
|
|
||||||
fontSize: '1rem'
|
|
||||||
}}>
|
|
||||||
{worksheets.filter(ws => !ws.isComment).length} Worksheet(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Worksheet-Einträge */}
|
{/* Worksheet-Einträge */}
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{worksheets.map((ws, index) => (
|
{worksheets.map((ws, index) => {
|
||||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
const isExpanded = expandedWorksheets[ws.wsid] || false
|
||||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
const isGit = ws.serviceType === 'GIT'
|
||||||
}}>
|
|
||||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
return (
|
||||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||||
}} onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
|
|
||||||
}} onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
}}>
|
||||||
<div className="card-header d-flex justify-content-between align-items-center py-3" style={{
|
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||||
background: ws.isComment
|
borderLeft: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
borderRadius: '8px',
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
color: 'white',
|
}} onMouseEnter={(e) => {
|
||||||
border: 'none'
|
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
|
||||||
|
}} onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
}}>
|
}}>
|
||||||
<div>
|
{/* Header - Immer sichtbar, klickbar */}
|
||||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
<div
|
||||||
{ws.isComment && (
|
className="card-header d-flex justify-content-between align-items-center py-3"
|
||||||
<span className="badge ms-2" style={{
|
onClick={() => toggleWorksheet(ws.wsid)}
|
||||||
background: 'rgba(255,255,255,0.3)'
|
style={{
|
||||||
}}>
|
background: isGit
|
||||||
<FaComment className="me-1" /> Kommentar
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
</span>
|
: ws.isComment
|
||||||
)}
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
</div>
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
<small style={{ opacity: 0.9 }}>
|
color: 'white',
|
||||||
{formatDateTime(ws.startDate, ws.startTime)}
|
border: 'none',
|
||||||
</small>
|
cursor: 'pointer',
|
||||||
</div>
|
userSelect: 'none',
|
||||||
|
transition: 'background 0.2s ease'
|
||||||
<div className="card-body p-4">
|
}}
|
||||||
{/* Mitarbeiter & Zeit */}
|
onMouseEnter={(e) => {
|
||||||
<div className="row mb-3">
|
e.currentTarget.style.background = isGit
|
||||||
<div className="col-md-6">
|
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
|
||||||
<div className="d-flex align-items-center">
|
: ws.isComment
|
||||||
<FaUser className="me-2" style={{ color: '#10b981' }} />
|
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||||
<strong>{ws.employeeName}</strong>
|
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
||||||
{ws.employeeShort && (
|
}}
|
||||||
<span className="badge ms-2" style={{
|
onMouseLeave={(e) => {
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
e.currentTarget.style.background = isGit
|
||||||
color: 'white'
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
}}>{ws.employeeShort}</span>
|
: ws.isComment
|
||||||
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronUp style={{ fontSize: '0.9rem', opacity: 0.8 }} />
|
||||||
|
) : (
|
||||||
|
<FaChevronDown style={{ fontSize: '0.9rem', opacity: 0.8 }} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong className="fs-6">WSID {ws.wsid}</strong>
|
||||||
|
{ws.isComment && !isGit && (
|
||||||
|
<span className="badge ms-2" style={{
|
||||||
|
background: 'rgba(255,255,255,0.3)'
|
||||||
|
}}>
|
||||||
|
<FaComment className="me-1" /> Kommentar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{needsTimeEntry(ws) && (
|
||||||
|
<span className="badge ms-2" style={{
|
||||||
|
background: 'rgba(255,255,255,0.3)'
|
||||||
|
}}>
|
||||||
|
<FaClock className="me-1" /> Zeit offen
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
||||||
<div className="col-md-6 text-md-end">
|
{!isExpanded && (
|
||||||
{!ws.isComment && (
|
<div className="d-flex align-items-center gap-3 ms-3">
|
||||||
<div className="d-flex align-items-center justify-content-md-end">
|
<div className="d-flex align-items-center">
|
||||||
<FaClock className="me-2" style={{ color: '#10b981' }} />
|
<FaUser style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
|
||||||
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
|
<span style={{ fontSize: '0.9rem' }}>{ws.employeeName}</span>
|
||||||
|
</div>
|
||||||
|
{!ws.isComment && (
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<FaClock style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
|
||||||
|
<span style={{ fontSize: '0.9rem' }}>{formatTime(ws.totalTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="badge" style={{
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}>{ws.serviceType}</span>
|
||||||
</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 }}>
|
||||||
|
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Body - Nur wenn expanded */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
className="card-body p-4"
|
||||||
|
style={{
|
||||||
|
animation: 'slideDown 0.3s ease-out'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mitarbeiter & Zeit */}
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<FaUser className="me-2" style={{ color: '#10b981' }} />
|
||||||
|
<strong>{ws.employeeName}</strong>
|
||||||
|
{ws.employeeShort && (
|
||||||
|
<span className="badge ms-2" style={{
|
||||||
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
|
color: 'white'
|
||||||
|
}}>{ws.employeeShort}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 text-md-end">
|
||||||
|
{!ws.isComment && (
|
||||||
|
<div className="d-flex align-items-center justify-content-md-end">
|
||||||
|
<FaClock className="me-2" style={{ color: '#10b981' }} />
|
||||||
|
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Service Type */}
|
{/* Service Type */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<span className="badge px-3 py-2" style={{
|
<span className="badge px-3 py-2" style={{
|
||||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: '0.85rem'
|
fontSize: '0.85rem'
|
||||||
}}>{ws.serviceType}</span>
|
}}>{ws.serviceType}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status-Änderung */}
|
{/* Status-Änderung */}
|
||||||
{ws.oldStatus !== ws.newStatus && (
|
{ws.oldStatus !== ws.newStatus && (
|
||||||
<div className="mb-3 p-3 rounded-3" style={{
|
<div className="mb-3 p-3 rounded-3" style={{
|
||||||
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
|
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
|
||||||
}}>
|
}}>
|
||||||
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
||||||
<span className="text-muted">Status:</span>{' '}
|
<span className="text-muted">Status:</span>{' '}
|
||||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
||||||
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
||||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response Level-Änderung */}
|
||||||
|
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-muted">Response Level:</span>{' '}
|
||||||
|
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
|
||||||
|
<span className="mx-2">→</span>
|
||||||
|
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="mt-3 p-3 rounded-3" style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.05)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.1)'
|
||||||
|
}}>
|
||||||
|
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
|
||||||
|
{ws.details}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Response Level-Änderung */}
|
|
||||||
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-muted">Response Level:</span>{' '}
|
|
||||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
|
|
||||||
<span className="mx-2">→</span>
|
|
||||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<div className="mt-3 p-3 rounded-3" style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.05)',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.1)'
|
|
||||||
}}>
|
|
||||||
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
|
|
||||||
{ws.details}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -182,6 +263,18 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 1000px;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FaClock, FaUsers, FaHistory, FaChartLine } from 'react-icons/fa'
|
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
|
||||||
|
|
||||||
export default function WorksheetStats({ worksheets }) {
|
export default function WorksheetStats({ worksheets }) {
|
||||||
if (!worksheets || worksheets.length === 0) {
|
if (!worksheets || worksheets.length === 0) {
|
||||||
@@ -28,16 +28,6 @@ export default function WorksheetStats({ worksheets }) {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
// Status-Historie
|
|
||||||
const statusHistory = worksheets
|
|
||||||
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
|
|
||||||
.map(ws => ({
|
|
||||||
date: ws.startDate,
|
|
||||||
time: ws.startTime,
|
|
||||||
from: ws.oldStatus,
|
|
||||||
to: ws.newStatus,
|
|
||||||
employee: ws.employeeName
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Service Type Verteilung
|
// Service Type Verteilung
|
||||||
const byServiceType = worksheets.reduce((acc, ws) => {
|
const byServiceType = worksheets.reduce((acc, ws) => {
|
||||||
@@ -53,159 +43,269 @@ export default function WorksheetStats({ worksheets }) {
|
|||||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTimeShort = (time) => {
|
|
||||||
if (!time || time.length !== 4) return '-'
|
|
||||||
return `${time.substring(0, 2)}:${time.substring(2, 4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="worksheet-stats mb-4">
|
<div className="worksheet-stats" style={{
|
||||||
<div className="row g-4">
|
display: 'flex',
|
||||||
{/* Gesamtübersicht */}
|
flexDirection: 'column',
|
||||||
<div className="col-lg-4 col-md-6">
|
gap: '16px',
|
||||||
<div className="card h-100 border-0 shadow-sm" style={{
|
height: '100%'
|
||||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
}}>
|
||||||
color: 'white'
|
{/* Gesamtübersicht */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
flex: '0 0 auto'
|
||||||
|
}}>
|
||||||
|
<h6 style={{
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<FaChartLine size={16} style={{ color: '#10b981' }} />
|
||||||
|
Gesamtübersicht
|
||||||
|
</h6>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px',
|
||||||
|
background: 'rgba(26, 32, 44, 0.4)',
|
||||||
|
borderRadius: '6px'
|
||||||
}}>
|
}}>
|
||||||
<div className="card-body p-4">
|
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Worksheets:</span>
|
||||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.length}</strong>
|
||||||
<FaChartLine className="me-2" size={20} style={{ color: '#4ade80' }} />
|
|
||||||
<strong>Gesamtübersicht</strong>
|
|
||||||
</h6>
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
|
||||||
<span style={{ opacity: 0.9 }}>Worksheets:</span>
|
|
||||||
<strong className="fs-5">{worksheets.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
|
||||||
<span style={{ opacity: 0.9 }}>Arbeitszeit:</span>
|
|
||||||
<strong className="fs-5">{formatTime(totalMinutes)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
|
||||||
<span style={{ opacity: 0.9 }}>Kommentare:</span>
|
|
||||||
<strong className="fs-5">{worksheets.filter(ws => ws.isComment).length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<span style={{ opacity: 0.9 }}>Ø pro Worksheet:</span>
|
|
||||||
<strong className="fs-5">
|
|
||||||
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
{/* Nach Mitarbeiter */}
|
justifyContent: 'space-between',
|
||||||
<div className="col-lg-4 col-md-6">
|
alignItems: 'center',
|
||||||
<div className="card h-100 border-0 shadow-sm" style={{
|
padding: '8px',
|
||||||
background: 'linear-gradient(135deg, #22c55e 0%, #10b981 100%)',
|
background: 'rgba(26, 32, 44, 0.4)',
|
||||||
color: 'white'
|
borderRadius: '6px'
|
||||||
}}>
|
}}>
|
||||||
<div className="card-body p-4">
|
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Arbeitszeit:</span>
|
||||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
<strong style={{ color: '#10b981', fontSize: '16px' }}>{formatTime(totalMinutes)}</strong>
|
||||||
<FaUsers className="me-2" size={20} />
|
|
||||||
<strong>Nach Mitarbeiter</strong>
|
|
||||||
</h6>
|
|
||||||
<div className="mt-3">
|
|
||||||
{Object.values(byEmployee).map((emp, idx) => (
|
|
||||||
<div key={idx} className="mb-3 pb-3" style={{ borderBottom: idx < Object.values(byEmployee).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<strong className="d-block">{emp.name}</strong>
|
|
||||||
{emp.short && (
|
|
||||||
<span className="badge mt-1" style={{
|
|
||||||
background: 'rgba(255,255,255,0.25)'
|
|
||||||
}}>{emp.short}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-end">
|
|
||||||
<div className="fs-5 fw-bold">{formatTime(emp.time)}</div>
|
|
||||||
<small style={{ opacity: 0.8 }}>{emp.count} WS</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
{/* Service Type Verteilung */}
|
justifyContent: 'space-between',
|
||||||
<div className="col-lg-4 col-md-6">
|
alignItems: 'center',
|
||||||
<div className="card h-100 border-0 shadow-sm" style={{
|
padding: '8px',
|
||||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
background: 'rgba(26, 32, 44, 0.4)',
|
||||||
color: 'white'
|
borderRadius: '6px'
|
||||||
}}>
|
}}>
|
||||||
<div className="card-body p-4">
|
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Kommentare:</span>
|
||||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.filter(ws => ws.isComment).length}</strong>
|
||||||
<FaClock className="me-2" size={20} style={{ color: '#4ade80' }} />
|
</div>
|
||||||
<strong>Service Types</strong>
|
<div style={{
|
||||||
</h6>
|
display: 'flex',
|
||||||
<div className="mt-3">
|
justifyContent: 'space-between',
|
||||||
{Object.entries(byServiceType).map(([type, count], idx) => (
|
alignItems: 'center',
|
||||||
<div key={type} className="d-flex justify-content-between align-items-center mb-3 pb-3" style={{ borderBottom: idx < Object.entries(byServiceType).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
|
padding: '8px',
|
||||||
<span className="badge px-3 py-2" style={{
|
background: 'rgba(26, 32, 44, 0.4)',
|
||||||
background: 'rgba(255,255,255,0.25)',
|
borderRadius: '6px'
|
||||||
fontSize: '0.9rem'
|
}}>
|
||||||
}}>{type}</span>
|
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Ø pro WS:</span>
|
||||||
<strong className="fs-5">{count}</strong>
|
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>
|
||||||
</div>
|
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
|
||||||
))}
|
</strong>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status-Historie */}
|
{/* Mitarbeiter Kombiniertes Diagramm */}
|
||||||
{statusHistory.length > 0 && (
|
<div style={{
|
||||||
<div className="card border-0 shadow-sm mt-3" style={{
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: '200px'
|
||||||
|
}}>
|
||||||
|
<h6 style={{
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
}}>
|
}}>
|
||||||
<div className="card-body p-4">
|
<FaUsers size={16} style={{ color: '#10b981' }} />
|
||||||
<h6 className="card-title text-white mb-3 d-flex align-items-center">
|
Mitarbeiter-Statistiken
|
||||||
<FaHistory className="me-2" size={20} />
|
</h6>
|
||||||
<strong>Status-Historie</strong>
|
|
||||||
</h6>
|
{Object.keys(byEmployee).length === 0 ? (
|
||||||
<div className="table-responsive mt-3">
|
<div style={{ color: '#a0aec0', textAlign: 'center', padding: '20px' }}>
|
||||||
<table className="table table-sm" style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
Keine Mitarbeiter-Daten verfügbar
|
||||||
<thead>
|
|
||||||
<tr style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Datum</th>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Zeit</th>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Von</th>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}></th>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Nach</th>
|
|
||||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Mitarbeiter</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{statusHistory.reverse().map((change, idx) => (
|
|
||||||
<tr key={idx} style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.date}</td>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{formatTimeShort(change.time)}</td>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
|
||||||
<span className="badge" style={{
|
|
||||||
background: 'rgba(255,255,255,0.25)'
|
|
||||||
}}>{change.from}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>→</td>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
|
||||||
<span className="badge" style={{
|
|
||||||
background: 'rgba(255,255,255,0.4)',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>{change.to}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.employee}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{(() => {
|
||||||
|
const employeeArray = Object.values(byEmployee).sort((a, b) => b.time - a.time)
|
||||||
|
const maxTime = Math.max(...employeeArray.map(e => e.time), 1)
|
||||||
|
|
||||||
|
return employeeArray.map((emp, idx) => {
|
||||||
|
const percentage = maxTime > 0 ? (emp.time / maxTime) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
minWidth: '120px'
|
||||||
|
}}>
|
||||||
|
{emp.short && (
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.2)',
|
||||||
|
color: '#10b981',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '9px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>{emp.short}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{emp.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '28px',
|
||||||
|
background: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
{/* WS Anzahl am Anfang */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '8px',
|
||||||
|
zIndex: 2,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}>
|
||||||
|
<span>WS</span>
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>{emp.count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balken mit Zeit */}
|
||||||
|
<div style={{
|
||||||
|
width: `${percentage}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(90deg, #10b981 0%, #059669 100%)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingLeft: '60px',
|
||||||
|
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{/* Zeit am Ende des Balkens */}
|
||||||
|
<span style={{
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{formatTime(emp.time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zeit außerhalb des Balkens (falls Balken zu kurz) */}
|
||||||
|
{percentage < 30 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '8px',
|
||||||
|
zIndex: 2,
|
||||||
|
color: '#10b981',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{formatTime(emp.time)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Type Verteilung */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
flex: '0 0 auto'
|
||||||
|
}}>
|
||||||
|
<h6 style={{
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<FaClock size={16} style={{ color: '#10b981' }} />
|
||||||
|
Service Types
|
||||||
|
</h6>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{Object.entries(byServiceType).map(([type, count]) => (
|
||||||
|
<div key={type} style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px',
|
||||||
|
background: 'rgba(26, 32, 44, 0.4)',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
color: 'var(--dark-text)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>{type}</span>
|
||||||
|
<strong style={{
|
||||||
|
color: '#10b981',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>{count}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/components/ui/Badge.jsx
Normal file
8
src/components/ui/Badge.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge-${tone} ${className}`} style={style}>
|
||||||
|
{dot && <span className="dot" />}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/ui/Button.jsx
Normal file
23
src/components/ui/Button.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const VARIANTS = {
|
||||||
|
default: 'ui-btn',
|
||||||
|
primary: 'ui-btn ui-btn-primary',
|
||||||
|
ghost: 'ui-btn ui-btn-ghost',
|
||||||
|
danger: 'ui-btn ui-btn-danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = 'default',
|
||||||
|
size,
|
||||||
|
as = 'button',
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const cls = `${VARIANTS[variant] || VARIANTS.default}${size === 'sm' ? ' ui-btn-sm' : ''} ${className}`
|
||||||
|
const Comp = as
|
||||||
|
return (
|
||||||
|
<Comp className={cls} {...props}>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/components/ui/Card.jsx
Normal file
18
src/components/ui/Card.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
|
||||||
|
if (!title && !actions) {
|
||||||
|
return (
|
||||||
|
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`ui-card ${className}`} style={style}>
|
||||||
|
<div className="ui-card-head">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{actions && <div className="flex gap-2 items-center">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="ui-card-body" style={bodyStyle}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
src/components/ui/EmptyState.jsx
Normal file
10
src/components/ui/EmptyState.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function EmptyState({ icon, title, hint, action }) {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
{icon && <div className="empty-icon">{icon}</div>}
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div>
|
||||||
|
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>}
|
||||||
|
{action && <div style={{ marginTop: 16 }}>{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/components/ui/Field.jsx
Normal file
9
src/components/ui/Field.jsx
Normal 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
10
src/components/ui/Kpi.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
|
||||||
|
return (
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi-accent" style={{ background: accent }} />
|
||||||
|
<div className="kpi-label">{icon}{label}</div>
|
||||||
|
<div className="kpi-value">{value}</div>
|
||||||
|
{sub && <div className="kpi-sub">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/ui/PageHeader.jsx
Normal file
12
src/components/ui/PageHeader.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default function PageHeader({ title, subtitle, actions, children }) {
|
||||||
|
return (
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">{title}</h1>
|
||||||
|
{subtitle && <div className="page-subtitle">{subtitle}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="page-actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ui/StatusPill.jsx
Normal file
36
src/components/ui/StatusPill.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const STATUS_CLASS = {
|
||||||
|
Open: 'status-open',
|
||||||
|
Occupied: 'status-occupied',
|
||||||
|
Assigned: 'status-assigned',
|
||||||
|
Awaiting: 'status-awaiting',
|
||||||
|
'Added Info': 'status-awaiting',
|
||||||
|
Closed: 'status-closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY = {
|
||||||
|
0: { cls: 'priority-none', label: 'Keine' },
|
||||||
|
1: { cls: 'priority-low', label: 'Niedrig' },
|
||||||
|
2: { cls: 'priority-medium', label: 'Mittel' },
|
||||||
|
3: { cls: 'priority-high', label: 'Hoch' },
|
||||||
|
4: { cls: 'priority-critical', label: 'Kritisch' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillBase = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '3px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ status }) {
|
||||||
|
const cls = STATUS_CLASS[status] || 'status-open'
|
||||||
|
return <span className={cls} style={pillBase}>{status || 'Open'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityPill({ priority }) {
|
||||||
|
const p = PRIORITY[priority] ?? PRIORITY[1]
|
||||||
|
return <span className={p.cls} style={pillBase}>{p.label}</span>
|
||||||
|
}
|
||||||
16
src/components/ui/Tabs.jsx
Normal file
16
src/components/ui/Tabs.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default function Tabs({ tabs, active, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="ticket-tabs">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={`ticket-tab ${active === t.id ? 'ticket-tab-active' : ''}`}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/components/ui/index.js
Normal file
9
src/components/ui/index.js
Normal 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'
|
||||||
@@ -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))
|
||||||
@@ -59,18 +119,24 @@ export function AuthProvider({ children }) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
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,45 +145,45 @@ 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)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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 15–30 Minuten warten und es erneut versuchen.'
|
||||||
|
} else if (errorMessage.includes('missing scopes') && errorMessage.includes('account')) {
|
||||||
|
errorMessage = 'Session konnte nicht gespeichert werden. Bitte Seite neu laden und erneut versuchen.'
|
||||||
|
} else if (
|
||||||
|
error?.type === 'user_session_already_exists' ||
|
||||||
|
errorMessage.includes('session is active')
|
||||||
|
) {
|
||||||
|
errorMessage = 'Es gibt noch eine alte Sitzung. Bitte Seite neu laden und erneut anmelden.'
|
||||||
|
} else if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
||||||
errorMessage = 'Ungültige Email oder Passwort'
|
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.'
|
||||||
} else if (errorMessage.includes('Email/Password')) {
|
} else if (errorMessage.includes('Email/Password')) {
|
||||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: errorMessage }
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,57 +194,25 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(null)
|
setUser(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(email, password, name) {
|
const isAdmin = Boolean(user?.labels?.includes('admin'))
|
||||||
if (DEMO_MODE) {
|
|
||||||
return login(email, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appwrite SDK 13.0 verwendet ID.unique() für die User ID
|
|
||||||
await account.create(ID.unique(), email, password, name)
|
|
||||||
// Login ruft automatisch ensureEmployeeExists auf
|
|
||||||
await login(email, password)
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Register error:', error)
|
|
||||||
let errorMessage = error.message || 'Registrierung fehlgeschlagen'
|
|
||||||
|
|
||||||
// Bessere Fehlermeldungen
|
|
||||||
if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
|
|
||||||
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
|
|
||||||
} else if (errorMessage.includes('Email/Password')) {
|
|
||||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
|
||||||
} else if (errorMessage.includes('password') && errorMessage.includes('length')) {
|
|
||||||
errorMessage = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: errorMessage }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
|
|
||||||
const isAdmin = () => {
|
|
||||||
if (!user) return false
|
|
||||||
// Prüfe ob Benutzer das "admin" Label hat
|
|
||||||
return user.labels?.includes('admin') || false
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
register,
|
isAdmin
|
||||||
isAdmin: isAdmin()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
const response = await databases.listDocuments(
|
try {
|
||||||
DATABASE_ID,
|
const response = await listCustomersForAdmin()
|
||||||
COLLECTIONS.CUSTOMERS,
|
setCustomers(response.customers || [])
|
||||||
[Query.orderAsc('name')]
|
} catch {
|
||||||
)
|
const response = await databases.listDocuments(
|
||||||
setCustomers(response.documents)
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.CUSTOMERS,
|
||||||
|
[Query.orderAsc('name')]
|
||||||
|
)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
if (DEMO_MODE) {
|
|
||||||
setEmployees(DEMO_EMPLOYEES)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
if (DEMO_MODE) {
|
||||||
DATABASE_ID,
|
setEmployees(DEMO_EMPLOYEES)
|
||||||
COLLECTIONS.EMPLOYEES,
|
} else {
|
||||||
[Query.orderAsc('displayName')]
|
try {
|
||||||
)
|
const result = await listEmployeesForAdmin()
|
||||||
setEmployees(response.documents)
|
setEmployees(result.employees || [])
|
||||||
|
} catch {
|
||||||
|
const result = await databases.listDocuments(
|
||||||
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.EMPLOYEES,
|
||||||
|
[Query.orderAsc('displayName')]
|
||||||
|
)
|
||||||
|
setEmployees(result.documents || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching employees:', err)
|
setEmployees([])
|
||||||
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
|
setError(err.message || 'Mitarbeiter konnten nicht geladen werden')
|
||||||
if (err.code === 404 || err.message?.includes('not found')) {
|
|
||||||
setEmployees([])
|
|
||||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
|
||||||
} 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(),
|
||||||
const response = await databases.createDocument(
|
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.EMPLOYEES,
|
|
||||||
ID.unique(),
|
|
||||||
{
|
|
||||||
userId: data.userId,
|
|
||||||
displayName: data.displayName,
|
|
||||||
email: data.email || '',
|
|
||||||
shortcode: data.shortcode || ''
|
|
||||||
}
|
}
|
||||||
)
|
setEmployees((current) => [...current, employee])
|
||||||
setEmployees(prev => [...prev, response])
|
return { success: true, data: employee }
|
||||||
return { success: true, data: response }
|
}
|
||||||
|
const result = await createEmployeeWithLogin(data)
|
||||||
|
setEmployees((current) => [...current, result.employee])
|
||||||
|
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) => {
|
||||||
if (DEMO_MODE) {
|
|
||||||
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await databases.updateDocument(
|
if (DEMO_MODE) {
|
||||||
DATABASE_ID,
|
setEmployees((current) =>
|
||||||
COLLECTIONS.EMPLOYEES,
|
current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee)
|
||||||
id,
|
)
|
||||||
data
|
return { success: true }
|
||||||
|
}
|
||||||
|
const result = await updateEmployeeWithLogin(id, data)
|
||||||
|
setEmployees((current) =>
|
||||||
|
current.map((employee) => employee.$id === id ? result.employee : employee)
|
||||||
)
|
)
|
||||||
setEmployees(prev => prev.map(e => e.$id === id ? response : e))
|
return { success: true, data: 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/hooks/useFinanceSummary.js
Normal file
24
src/hooks/useFinanceSummary.js
Normal 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
49
src/hooks/useInvoices.js
Normal 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
282
src/hooks/useLeads.js
Normal 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 }
|
||||||
|
}
|
||||||
84
src/hooks/useWebsiteProjects.js
Normal file
84
src/hooks/useWebsiteProjects.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,11 +1,76 @@
|
|||||||
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 DEMO_WORKORDERS = [
|
const DEMO_WORKORDERS = [
|
||||||
{ $id: '1', woid: '10001', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
{
|
||||||
|
$id: 'dummy-10001',
|
||||||
|
woid: '10001',
|
||||||
|
topic: 'Kompletter Systemausfall - Server & Netzwerk',
|
||||||
|
status: 'Assigned',
|
||||||
|
priority: 4,
|
||||||
|
type: 'Emergency Call',
|
||||||
|
systemType: 'Server',
|
||||||
|
responseLevel: 'Backoffice',
|
||||||
|
serviceType: 'On Site',
|
||||||
|
customerName: 'Kunde A',
|
||||||
|
customerLocation: 'Hauptstraße 123, 12345 Musterstadt',
|
||||||
|
assignedTo: 'user-max-id',
|
||||||
|
assignedName: 'Max Mustermann',
|
||||||
|
requestedBy: 'Dr. Anna Schmidt',
|
||||||
|
requestedFor: 'IT-Abteilung Kunde A',
|
||||||
|
startDate: '30.12.2025',
|
||||||
|
startTime: '0800',
|
||||||
|
deadline: '31.12.2025',
|
||||||
|
endTime: '1800',
|
||||||
|
estimate: '480',
|
||||||
|
mailCopyTo: 'admin@kunde-a.de, it@kunde-a.de',
|
||||||
|
sendNotification: true,
|
||||||
|
details: `KRITISCHER SYSTEMAUSFALL - SOFORTIGE BEARBEITUNG ERFORDERLICH
|
||||||
|
|
||||||
|
Problembeschreibung:
|
||||||
|
- Kompletter Serverausfall im Rechenzentrum
|
||||||
|
- Alle Server sind offline (keine Verbindung möglich)
|
||||||
|
- Netzwerk-Infrastruktur betroffen
|
||||||
|
- Keine Backup-Systeme verfügbar
|
||||||
|
|
||||||
|
Betroffene Systeme:
|
||||||
|
- Hauptserver (Windows Server 2022)
|
||||||
|
- Datenbankserver (SQL Server 2019)
|
||||||
|
- Fileserver
|
||||||
|
- Exchange Server
|
||||||
|
- Netzwerk-Switches
|
||||||
|
|
||||||
|
Auswirkungen:
|
||||||
|
- Keine E-Mail-Kommunikation möglich
|
||||||
|
- Alle Anwendungen offline
|
||||||
|
- Kein Zugriff auf Datenbanken
|
||||||
|
- Produktion steht still
|
||||||
|
|
||||||
|
Dringlichkeit: KRITISCH - Produktionsausfall
|
||||||
|
|
||||||
|
Erwartete Bearbeitungszeit: 8 Stunden
|
||||||
|
Benötigte Ressourcen: 2 Techniker, Hardware-Ersatzteile`,
|
||||||
|
approvalStatus: 'approved',
|
||||||
|
$createdAt: lastWeek.toISOString(),
|
||||||
|
createdAt: lastWeek.toISOString()
|
||||||
|
},
|
||||||
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
|
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
|
||||||
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||||
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
|
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
|
||||||
@@ -16,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)
|
||||||
@@ -40,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 (activeFilters.type && activeFilters.type.length > 0) {
|
||||||
|
queries.push(Query.equal('type', activeFilters.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||||
|
queries.push(Query.equal('priority', activeFilters.priority))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.type && filters.type.length > 0) {
|
if (activeFilters.customerId) {
|
||||||
if (filters.type.length === 1) {
|
queries.push(Query.equal('customerId', activeFilters.customerId))
|
||||||
queries.push(Query.equal('type', filters.type[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.priority && filters.priority.length > 0) {
|
if (activeFilters.assignedTo) {
|
||||||
if (filters.priority.length === 1) {
|
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
||||||
queries.push(Query.equal('priority', filters.priority[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.customerId) {
|
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
|
||||||
queries.push(Query.equal('customerId', filters.customerId))
|
if (activeFilters.woid) {
|
||||||
}
|
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||||
|
|
||||||
if (filters.assignedTo) {
|
|
||||||
queries.push(Query.equal('assignedTo', filters.assignedTo))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Zeige Collection ID
|
// Debug: Zeige Collection ID
|
||||||
@@ -88,23 +163,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
COLLECTIONS.WORKORDERS,
|
COLLECTIONS.WORKORDERS,
|
||||||
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'
|
||||||
@@ -121,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
|
||||||
@@ -146,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 || '',
|
||||||
@@ -185,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 || '',
|
||||||
|
|||||||
@@ -1,30 +1,146 @@
|
|||||||
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
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||||
|
const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000)
|
||||||
|
const threeDaysAgo = new Date(Date.now() - 72 * 60 * 60 * 1000)
|
||||||
|
|
||||||
// Demo data für Testing
|
|
||||||
const DEMO_WORKSHEETS = [
|
const DEMO_WORKSHEETS = [
|
||||||
{
|
{
|
||||||
$id: '1',
|
$id: 'ws-10001-001',
|
||||||
wsid: '100001',
|
wsid: '100001',
|
||||||
woid: '10001',
|
woid: '10001',
|
||||||
workorderId: '1',
|
workorderId: 'dummy-10001',
|
||||||
employeeId: 'emp1',
|
employeeId: 'user-max-id',
|
||||||
employeeName: 'Max Müller',
|
employeeName: 'Max Mustermann',
|
||||||
employeeShort: 'MAMU',
|
employeeShort: 'MM',
|
||||||
serviceType: 'Remote',
|
serviceType: 'Remote',
|
||||||
oldStatus: 'Open',
|
oldStatus: 'Open',
|
||||||
newStatus: 'Occupied',
|
newStatus: 'Occupied',
|
||||||
|
oldResponseLevel: '',
|
||||||
|
newResponseLevel: '24/7',
|
||||||
totalTime: 30,
|
totalTime: 30,
|
||||||
startDate: '29.12.2025',
|
startDate: '23.12.2025',
|
||||||
startTime: '1000',
|
startTime: '0800',
|
||||||
endDate: '29.12.2025',
|
endDate: '23.12.2025',
|
||||||
endTime: '1030',
|
endTime: '0830',
|
||||||
details: 'Router neu gestartet',
|
details: 'Erste Analyse durchgeführt. Server komplett offline. Keine Remote-Verbindung möglich. Vor-Ort-Einsatz erforderlich.',
|
||||||
isComment: false,
|
isComment: false,
|
||||||
$createdAt: new Date().toISOString()
|
$createdAt: threeDaysAgo.toISOString()
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
$id: 'ws-10001-002',
|
||||||
|
wsid: '100002',
|
||||||
|
woid: '10001',
|
||||||
|
workorderId: 'dummy-10001',
|
||||||
|
employeeId: 'user-lisa-id',
|
||||||
|
employeeName: 'Lisa Schneider',
|
||||||
|
employeeShort: 'LS',
|
||||||
|
serviceType: 'On Site',
|
||||||
|
oldStatus: 'Occupied',
|
||||||
|
newStatus: 'Assigned',
|
||||||
|
oldResponseLevel: '24/7',
|
||||||
|
newResponseLevel: '24/7',
|
||||||
|
totalTime: 120,
|
||||||
|
startDate: '23.12.2025',
|
||||||
|
startTime: '1000',
|
||||||
|
endDate: '23.12.2025',
|
||||||
|
endTime: '1200',
|
||||||
|
details: 'Vor-Ort-Einsatz: Hardware-Check durchgeführt. Netzteil des Hauptservers defekt. Ersatzteil bestellt. Notfall-Backup-Server gestartet.',
|
||||||
|
isComment: false,
|
||||||
|
$createdAt: threeDaysAgo.toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'ws-10001-003',
|
||||||
|
wsid: '100003',
|
||||||
|
woid: '10001',
|
||||||
|
workorderId: 'dummy-10001',
|
||||||
|
employeeId: 'user-tom-id',
|
||||||
|
employeeName: 'Tom Klein',
|
||||||
|
employeeShort: 'TK',
|
||||||
|
serviceType: 'On Site',
|
||||||
|
oldStatus: 'Assigned',
|
||||||
|
newStatus: 'Assigned',
|
||||||
|
oldResponseLevel: '24/7',
|
||||||
|
newResponseLevel: '24/7',
|
||||||
|
totalTime: 0,
|
||||||
|
startDate: '24.12.2025',
|
||||||
|
startTime: '1400',
|
||||||
|
endDate: '24.12.2025',
|
||||||
|
endTime: '1400',
|
||||||
|
details: 'Warte auf Ersatzteil-Lieferung. Kunde informiert. Backup-System läuft stabil.',
|
||||||
|
isComment: true,
|
||||||
|
$createdAt: twoDaysAgo.toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'ws-10001-004',
|
||||||
|
wsid: '100004',
|
||||||
|
woid: '10001',
|
||||||
|
workorderId: 'dummy-10001',
|
||||||
|
employeeId: 'user-max-id',
|
||||||
|
employeeName: 'Max Mustermann',
|
||||||
|
employeeShort: 'MM',
|
||||||
|
serviceType: 'On Site',
|
||||||
|
oldStatus: 'Assigned',
|
||||||
|
newStatus: 'In Test',
|
||||||
|
oldResponseLevel: '24/7',
|
||||||
|
newResponseLevel: '24/7',
|
||||||
|
totalTime: 180,
|
||||||
|
startDate: '25.12.2025',
|
||||||
|
startTime: '0900',
|
||||||
|
endDate: '25.12.2025',
|
||||||
|
endTime: '1200',
|
||||||
|
details: 'Ersatzteil eingebaut. Server gestartet. Alle Dienste wiederhergestellt. System-Tests durchgeführt. Datenbank-Verbindungen geprüft.',
|
||||||
|
isComment: false,
|
||||||
|
$createdAt: twoDaysAgo.toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'ws-10001-005',
|
||||||
|
wsid: '100005',
|
||||||
|
woid: '10001',
|
||||||
|
workorderId: 'dummy-10001',
|
||||||
|
employeeId: 'user-lisa-id',
|
||||||
|
employeeName: 'Lisa Schneider',
|
||||||
|
employeeShort: 'LS',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
oldStatus: 'In Test',
|
||||||
|
newStatus: 'Awaiting',
|
||||||
|
oldResponseLevel: '24/7',
|
||||||
|
newResponseLevel: 'Support',
|
||||||
|
totalTime: 45,
|
||||||
|
startDate: '26.12.2025',
|
||||||
|
startTime: '1000',
|
||||||
|
endDate: '26.12.2025',
|
||||||
|
endTime: '1045',
|
||||||
|
details: 'Remote-Monitoring eingerichtet. Warte auf Kunden-Feedback nach 24h Testphase. Alle Systeme laufen stabil.',
|
||||||
|
isComment: false,
|
||||||
|
$createdAt: yesterday.toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'ws-10001-006',
|
||||||
|
wsid: '100006',
|
||||||
|
woid: '10001',
|
||||||
|
workorderId: 'dummy-10001',
|
||||||
|
employeeId: 'user-tom-id',
|
||||||
|
employeeName: 'Tom Klein',
|
||||||
|
employeeShort: 'TK',
|
||||||
|
serviceType: 'COMMENT',
|
||||||
|
oldStatus: 'Awaiting',
|
||||||
|
newStatus: 'Closed',
|
||||||
|
oldResponseLevel: 'Support',
|
||||||
|
newResponseLevel: 'Backoffice',
|
||||||
|
totalTime: 0,
|
||||||
|
startDate: '30.12.2025',
|
||||||
|
startTime: '0900',
|
||||||
|
endDate: '30.12.2025',
|
||||||
|
endTime: '0900',
|
||||||
|
details: 'Kunde bestätigt: Alle Systeme funktionieren einwandfrei. Problem vollständig behoben. Ticket kann geschlossen werden.',
|
||||||
|
isComment: true,
|
||||||
|
$createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useWorksheets(woid = null) {
|
export function useWorksheets(woid = null) {
|
||||||
|
|||||||
@@ -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,44 @@ 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',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUCKET_ID = 'woms-attachments'
|
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
||||||
|
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 }
|
||||||
|
|||||||
40
src/lib/customerAdminApi.js
Normal file
40
src/lib/customerAdminApi.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { account } from './appwrite'
|
||||||
|
|
||||||
|
const PROJECT_ADMIN_URL =
|
||||||
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
|
|
||||||
|
async function adminFetch(path, { method = 'POST', body } = {}) {
|
||||||
|
const jwt = await account.createJWT()
|
||||||
|
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt.jwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `API-Fehler ${response.status}`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomerWithPortalAccess(payload) {
|
||||||
|
return adminFetch('/api/admin/customers', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomersForAdmin() {
|
||||||
|
return adminFetch('/api/admin/customers', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerWithPortalAccess(customerId, payload) {
|
||||||
|
return adminFetch(`/api/admin/customers/${customerId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomerWithPortalAccess(customerId) {
|
||||||
|
return adminFetch(`/api/admin/customers/${customerId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
40
src/lib/employeeAdminApi.js
Normal file
40
src/lib/employeeAdminApi.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { account } from './appwrite'
|
||||||
|
|
||||||
|
const PROJECT_ADMIN_URL =
|
||||||
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
|
|
||||||
|
async function adminFetch(path, { method = 'GET', body } = {}) {
|
||||||
|
const jwt = await account.createJWT()
|
||||||
|
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt.jwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `API-Fehler ${response.status}`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEmployeesForAdmin() {
|
||||||
|
return adminFetch('/api/admin/employees')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmployeeWithLogin(payload) {
|
||||||
|
return adminFetch('/api/admin/employees', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEmployeeWithLogin(employeeId, payload) {
|
||||||
|
return adminFetch(`/api/admin/employees/${employeeId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEmployeeWithLogin(employeeId) {
|
||||||
|
return adminFetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
99
src/lib/integrationsApi.js
Normal file
99
src/lib/integrationsApi.js
Normal 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
130
src/lib/leads.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
|
||||||
|
import { integrationsApi } from './integrationsApi'
|
||||||
|
|
||||||
|
/** Pflichtfelder, damit eine Rechnung erstellt werden kann (wie InvoiceNinja). */
|
||||||
|
export function isInvoiceReady(c) {
|
||||||
|
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerStage(c) {
|
||||||
|
const s = (c?.customerStatus || '').toLowerCase()
|
||||||
|
if (s === 'lead') return 'lead'
|
||||||
|
if (s === 'lost') return 'lost'
|
||||||
|
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
|
||||||
|
export function isArchived(c) {
|
||||||
|
return Boolean(c?.archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kunde archivieren / wiederherstellen. */
|
||||||
|
export async function setCustomerArchived(customer, archived) {
|
||||||
|
return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
archived: Boolean(archived),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ninjaPayload(c) {
|
||||||
|
return {
|
||||||
|
name: c.companyName || c.name,
|
||||||
|
email: c.email || '',
|
||||||
|
phone: c.phone || '',
|
||||||
|
street: c.street || '',
|
||||||
|
city: c.city || '',
|
||||||
|
postalCode: c.postalCode || '',
|
||||||
|
country: c.country || '',
|
||||||
|
vatNumber: c.vatNumber || '',
|
||||||
|
contactName: c.contactName || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erstellt/aktualisiert den InvoiceNinja-Client und speichert die ID am Kunden. */
|
||||||
|
export async function syncCustomerToNinja(customer) {
|
||||||
|
if (customer.invoiceNinjaClientId) {
|
||||||
|
await integrationsApi.updateClient(customer.invoiceNinjaClientId, ninjaPayload(customer))
|
||||||
|
return customer.invoiceNinjaClientId
|
||||||
|
}
|
||||||
|
const r = await integrationsApi.createClient(ninjaPayload(customer))
|
||||||
|
const id = r.client?.id
|
||||||
|
if (id) {
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
invoiceNinjaClientId: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextWoid() {
|
||||||
|
try {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(200),
|
||||||
|
])
|
||||||
|
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
|
||||||
|
return String((nums.length ? Math.max(...nums) : 9999) + 1)
|
||||||
|
} catch {
|
||||||
|
return String(10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-Akquise-Ticket fuer einen potenziellen Kunden. */
|
||||||
|
export async function createAcquisitionTicket(customer, user) {
|
||||||
|
const woid = await nextWoid()
|
||||||
|
const data = {
|
||||||
|
topic: `Akquise: ${customer.name}`,
|
||||||
|
status: 'Open',
|
||||||
|
priority: 2,
|
||||||
|
woid,
|
||||||
|
type: 'Akquise',
|
||||||
|
systemType: 'n/a',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
customerId: customer.$id,
|
||||||
|
customerName: customer.name,
|
||||||
|
customerLocation: customer.location || '',
|
||||||
|
requestedBy: customer.email || '',
|
||||||
|
assignedTo: user?.$id || '',
|
||||||
|
details:
|
||||||
|
`Automatisch erstelltes Akquise-Ticket fuer den potenziellen Kunden "${customer.name}".\n` +
|
||||||
|
`Kontakt: ${customer.email || '-'}\n\n` +
|
||||||
|
`Worksheets dokumentieren den Kontaktverlauf (sichtbar in den WSIDs).\n` +
|
||||||
|
`Ergebnis im Worksheet auf "Zugesagt" -> Lead wird automatisch fester Kunde. "Abgesagt" -> Ticket geschlossen.`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Lead an (nur Unternehmen + E-Mail) und erstellt das Akquise-Ticket. */
|
||||||
|
export async function createLead({ company, email }, user) {
|
||||||
|
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||||
|
name: company,
|
||||||
|
companyName: company,
|
||||||
|
email: email || '',
|
||||||
|
customerStatus: 'lead',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
let ticket = null
|
||||||
|
try {
|
||||||
|
ticket = await createAcquisitionTicket(customer, user)
|
||||||
|
} catch {
|
||||||
|
/* Ticket ist optional - Lead bleibt bestehen */
|
||||||
|
}
|
||||||
|
return { customer, ticket }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht aus einem Lead einen festen Kunden (mit vollstaendigen Daten) und legt den InvoiceNinja-Client an. */
|
||||||
|
export async function upgradeToCustomer(customer, fullData) {
|
||||||
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
...fullData,
|
||||||
|
customerStatus: 'customer',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
let clientId = updated.invoiceNinjaClientId
|
||||||
|
try {
|
||||||
|
clientId = await syncCustomerToNinja(updated)
|
||||||
|
} catch (e) {
|
||||||
|
return { customer: updated, clientId: null, syncError: e.message }
|
||||||
|
}
|
||||||
|
return { customer: { ...updated, invoiceNinjaClientId: clientId }, clientId }
|
||||||
|
}
|
||||||
52
src/lib/projectAdminApi.js
Normal file
52
src/lib/projectAdminApi.js
Normal 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)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
456
src/pages/AdminPage.css
Normal file
456
src/pages/AdminPage.css
Normal 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
@@ -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
|
className="form-control"
|
||||||
type="text"
|
placeholder="Assets suchen..."
|
||||||
placeholder="Search assets..."
|
value={searchTerm}
|
||||||
value={searchTerm}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
style={{ maxWidth: 400, marginBottom: 16 }}
|
||||||
className="input"
|
/>
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/pages/CustomerDetailPage.jsx
Normal file
343
src/pages/CustomerDetailPage.jsx
Normal 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
144
src/pages/CustomersPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
111
src/pages/DocumentsPage.jsx
Normal 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
189
src/pages/FinancePage.jsx
Normal 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
640
src/pages/LeadsPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import LetterGlitch from '../components/LetterGlitch'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [isRegistering, setIsRegistering] = useState(false)
|
|
||||||
|
|
||||||
const { login, register } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
@@ -19,9 +18,7 @@ export default function LoginPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = isRegistering
|
const result = await login(email, password)
|
||||||
? await register(email, password, name || email.split('@')[0])
|
|
||||||
: await login(email, password)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/tickets')
|
navigate('/tickets')
|
||||||
@@ -31,12 +28,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('401')) {
|
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('401')) {
|
||||||
errorMessage = 'Ungültige Email oder Passwort. Bitte überprüfe deine Eingaben.'
|
errorMessage = 'Ungültige Email oder Passwort. Bitte überprüfe deine Eingaben.'
|
||||||
} else if (errorMessage.includes('User already exists')) {
|
|
||||||
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
|
|
||||||
setIsRegistering(false)
|
|
||||||
} else if (errorMessage.includes('User with the same email already exists')) {
|
|
||||||
errorMessage = 'Diese Email ist bereits registriert. Bitte logge dich ein.'
|
|
||||||
setIsRegistering(false)
|
|
||||||
} else if (errorMessage.includes('Email/Password') || errorMessage.includes('auth')) {
|
} else if (errorMessage.includes('Email/Password') || errorMessage.includes('auth')) {
|
||||||
errorMessage = 'Email/Password Authentifizierung ist möglicherweise nicht aktiviert. Bitte überprüfe deine Appwrite-Konfiguration.'
|
errorMessage = 'Email/Password Authentifizierung ist möglicherweise nicht aktiviert. Bitte überprüfe deine Appwrite-Konfiguration.'
|
||||||
}
|
}
|
||||||
@@ -52,95 +43,100 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
width: '100%',
|
||||||
alignItems: 'center',
|
overflow: 'hidden'
|
||||||
justifyContent: 'center',
|
|
||||||
background: '#f1f1f1'
|
|
||||||
}}>
|
}}>
|
||||||
<div className="card" style={{ width: '400px' }}>
|
{/* LetterGlitch Hintergrund */}
|
||||||
<div className="card-header text-center">
|
<div style={{
|
||||||
<h2>Webklar WOMS 2.0</h2>
|
position: 'absolute',
|
||||||
</div>
|
top: 0,
|
||||||
<div className="card-body">
|
left: 0,
|
||||||
<form onSubmit={handleSubmit}>
|
width: '100%',
|
||||||
{error && (
|
height: '100%',
|
||||||
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
zIndex: 0
|
||||||
{error}
|
}}>
|
||||||
</div>
|
<LetterGlitch
|
||||||
)}
|
glitchSpeed={50}
|
||||||
|
centerVignette={true}
|
||||||
{isRegistering && (
|
outerVignette={false}
|
||||||
|
smooth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login-Formular */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '400px',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<h2>Webklar WOMS 2.0</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Name (optional)</label>
|
<label className="form-label">Email</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={name}
|
value={email}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Dein Name"
|
required
|
||||||
|
placeholder="deine@email.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Email</label>
|
<label className="form-label">Password</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="password"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={email}
|
value={password}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="deine@email.com"
|
placeholder="••••••••"
|
||||||
/>
|
minLength={8}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<button
|
||||||
<label className="form-label">Password</label>
|
type="submit"
|
||||||
<input
|
className="btn btn-green"
|
||||||
type="password"
|
style={{ width: '100%' }}
|
||||||
className="form-control"
|
disabled={loading}
|
||||||
value={password}
|
>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
{loading ? 'Login läuft...' : 'Login'}
|
||||||
required
|
</button>
|
||||||
placeholder="••••••••"
|
</form>
|
||||||
minLength={8}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-green"
|
|
||||||
style={{ width: '100%', marginBottom: '10px' }}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? (isRegistering ? 'Registrierung läuft...' : 'Login läuft...')
|
|
||||||
: (isRegistering ? 'Registrieren' : 'Login')
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
color: '#333'
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setIsRegistering(!isRegistering)
|
|
||||||
setError('')
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{isRegistering
|
|
||||||
? 'Bereits registriert? Hier einloggen'
|
|
||||||
: 'Noch kein Account? Hier registrieren'
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,42 @@
|
|||||||
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 className="planboard-grid">
|
|
||||||
{weekDays.map(day => (
|
|
||||||
<div key={day.toISOString()} className="planboard-day">
|
|
||||||
<div className="day-header">
|
|
||||||
<strong>{format(day, 'EEEE')}</strong>
|
|
||||||
<span>{format(day, 'dd.MM')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="day-content">
|
|
||||||
<p className="text-grey text-small">No tasks scheduled</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="text-center mt-4 text-grey">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
|
||||||
<p>Drag and drop tickets to schedule them on the planboard.</p>
|
{weekDays.map((day) => (
|
||||||
|
<Card key={day.toISOString()} pad={false}>
|
||||||
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
|
||||||
|
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 12, minHeight: 120 }}>
|
||||||
|
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
|
||||||
|
<Row label="Subdomain">
|
||||||
|
{project.subdomain
|
||||||
|
? <code>{project.subdomain}</code>
|
||||||
|
: <span className="text-grey">ohne Website</span>}
|
||||||
|
</Row>
|
||||||
|
<Row label="Ticket">
|
||||||
|
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
|
||||||
|
</Row>
|
||||||
|
<Row label="Status">
|
||||||
|
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
style={{ fontSize: '13px' }}
|
||||||
|
value={project.customerId || ''}
|
||||||
|
disabled={assignBusyId === project.$id || project.archived}
|
||||||
|
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Kein Kunde</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>
|
||||||
|
({c.code || ''}) {c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-dark"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => openEdit(project)}
|
||||||
|
title="Bearbeiten"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
>
|
||||||
|
<FaPen />
|
||||||
|
</button>
|
||||||
|
{needsHosting ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-teal"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => handleHostNow(project)}
|
||||||
|
disabled={hostBusyId === project.$id}
|
||||||
|
title="Projekt jetzt deployen/hosten"
|
||||||
|
aria-label="Projekt jetzt deployen/hosten"
|
||||||
|
>
|
||||||
|
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
|
)}
|
||||||
|
{hosted && ready && !project.archived && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-dark"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => handleRefresh(project)}
|
||||||
|
disabled={refreshBusyId === project.$id}
|
||||||
|
title="Aktualisieren – Website neu deployen"
|
||||||
|
aria-label="Aktualisieren – Website neu deployen"
|
||||||
|
>
|
||||||
|
<FaArrowsRotate
|
||||||
|
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
</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://<subdomain>.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
<div className="filter-row mt-2">
|
<Button variant="primary" onClick={handleGenerate}><FaChartBar /> Bericht erstellen</Button>
|
||||||
<label>
|
<Button variant="ghost"><FaFileExport /> Als PDF exportieren</Button>
|
||||||
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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="text-center mt-2">
|
<div style={{ marginTop: 16 }}>
|
||||||
<button className="btn btn-teal" onClick={handleGenerateReport}>
|
<Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user